From 3149adebdbe077f56c56e58a452eb29b712e4ffe Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 10 Oct 2024 09:33:55 +0200 Subject: [PATCH 01/80] Expose the fact that a short URL has redirect rules attached to it --- docs/async-api/async-api.json | 12 +++++++++++- docs/swagger/definitions/ShortUrl.json | 7 ++++++- ...Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php | 5 +++++ module/Core/src/ShortUrl/Entity/ShortUrl.php | 4 ++++ 4 files changed, 26 insertions(+), 2 deletions(-) diff --git a/docs/async-api/async-api.json b/docs/async-api/async-api.json index 83c424ea..b2da154b 100644 --- a/docs/async-api/async-api.json +++ b/docs/async-api/async-api.json @@ -141,6 +141,14 @@ "crawlable": { "type": "boolean", "description": "Tells if this URL will be included as 'Allow' in Shlink's robots.txt." + }, + "forwardQuery": { + "type": "boolean", + "description": "Tells if this URL will forward the query params to the long URL when visited, as explained in [the docs](https://shlink.io/documentation/some-features/#query-params-forwarding)." + }, + "hasRedirectRules": { + "type": "boolean", + "description": "Whether this short URL has redirect rules attached to it or not. Use [this endpoint](https://api-spec.shlink.io/#/Redirect%20rules/listShortUrlRedirectRules) to get the actual list of rules." } }, "example": { @@ -164,7 +172,9 @@ }, "domain": "example.com", "title": "The title", - "crawlable": false + "crawlable": false, + "forwardQuery": false, + "hasRedirectRules": true } }, "ShortUrlMeta": { diff --git a/docs/swagger/definitions/ShortUrl.json b/docs/swagger/definitions/ShortUrl.json index 1535b65f..5de6f384 100644 --- a/docs/swagger/definitions/ShortUrl.json +++ b/docs/swagger/definitions/ShortUrl.json @@ -11,7 +11,8 @@ "domain", "title", "crawlable", - "forwardQuery" + "forwardQuery", + "hasRedirectRules" ], "properties": { "shortCode": { @@ -59,6 +60,10 @@ "forwardQuery": { "type": "boolean", "description": "Tells if this URL will forward the query params to the long URL when visited, as explained in [the docs](https://shlink.io/documentation/some-features/#query-params-forwarding)." + }, + "hasRedirectRules": { + "type": "boolean", + "description": "Whether this short URL has redirect rules attached to it or not. Use [this endpoint](https://api-spec.shlink.io/#/Redirect%20rules/listShortUrlRedirectRules) to get the actual list of rules." } } } diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php index b159da13..2277b0e5 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php @@ -110,4 +110,9 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->columnName('forward_query') ->option('default', true) ->build(); + + $builder->createOneToMany('redirectRules', RedirectRule\Entity\ShortUrlRedirectRule::class) + ->mappedBy('shortUrl') + ->fetchExtraLazy() + ->build(); }; diff --git a/module/Core/src/ShortUrl/Entity/ShortUrl.php b/module/Core/src/ShortUrl/Entity/ShortUrl.php index e394fb5a..6f4b59c6 100644 --- a/module/Core/src/ShortUrl/Entity/ShortUrl.php +++ b/module/Core/src/ShortUrl/Entity/ShortUrl.php @@ -12,6 +12,7 @@ use Doctrine\Common\Collections\Selectable; use Shlinkio\Shlink\Common\Entity\AbstractEntity; use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\Exception\ShortCodeCannotBeRegeneratedException; +use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; @@ -39,6 +40,7 @@ class ShortUrl extends AbstractEntity * @param Collection $tags * @param Collection & Selectable $visits * @param Collection & Selectable $visitsCounts + * @param Collection $redirectRules */ private function __construct( private string $longUrl, @@ -60,6 +62,7 @@ class ShortUrl extends AbstractEntity private bool $forwardQuery = true, private ?string $importSource = null, private ?string $importOriginalShortCode = null, + private Collection $redirectRules = new ArrayCollection(), ) { } @@ -283,6 +286,7 @@ class ShortUrl extends AbstractEntity Criteria::create()->where(Criteria::expr()->eq('potentialBot', false)), )), ), + 'hasRedirectRules' => count($this->redirectRules) > 0, ]; } } From 84a187a26f865cd4fef0efba7aa7d878a1cc0435 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 27 Oct 2024 11:20:44 +0100 Subject: [PATCH 02/80] Include left join with domains when listing short URLs to avoid N+1 SELECT problem --- module/Core/src/ShortUrl/Entity/ShortUrl.php | 10 ++++++-- .../Model/ShortUrlWithVisitsSummary.php | 23 ++++++++++++------- .../Repository/ShortUrlListRepository.php | 6 ++--- 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/module/Core/src/ShortUrl/Entity/ShortUrl.php b/module/Core/src/ShortUrl/Entity/ShortUrl.php index 6f4b59c6..35c0dfd2 100644 --- a/module/Core/src/ShortUrl/Entity/ShortUrl.php +++ b/module/Core/src/ShortUrl/Entity/ShortUrl.php @@ -264,7 +264,13 @@ class ShortUrl extends AbstractEntity return true; } - public function toArray(?VisitsSummary $precalculatedSummary = null): array + /** + * @param null|(callable(): string|null) $getAuthority - + * This is a callback so that we trust its return value if provided, even if it is null. + * Providing the raw authority as `string|null` would result in a fallback to `$this->domain` when the authority + * was null. + */ + public function toArray(?VisitsSummary $precalculatedSummary = null, callable|null $getAuthority = null): array { return [ 'shortCode' => $this->shortCode, @@ -276,7 +282,7 @@ class ShortUrl extends AbstractEntity 'validUntil' => $this->validUntil?->toAtomString(), 'maxVisits' => $this->maxVisits, ], - 'domain' => $this->domain, + 'domain' => $getAuthority !== null ? $getAuthority() : $this->domain?->authority, 'title' => $this->title, 'crawlable' => $this->crawlable, 'forwardQuery' => $this->forwardQuery, diff --git a/module/Core/src/ShortUrl/Model/ShortUrlWithVisitsSummary.php b/module/Core/src/ShortUrl/Model/ShortUrlWithVisitsSummary.php index 50efaaee..d5c34b8b 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlWithVisitsSummary.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlWithVisitsSummary.php @@ -9,19 +9,26 @@ use Shlinkio\Shlink\Core\Visit\Model\VisitsSummary; final readonly class ShortUrlWithVisitsSummary { - private function __construct(public ShortUrl $shortUrl, private ?VisitsSummary $visitsSummary = null) - { + private function __construct( + public ShortUrl $shortUrl, + private VisitsSummary|null $visitsSummary = null, + private string|null $authority = null, + ) { } /** - * @param array{shortUrl: ShortUrl, visits: string|int, nonBotVisits: string|int} $data + * @param array{shortUrl: ShortUrl, visits: string|int, nonBotVisits: string|int, authority: string|null} $data */ public static function fromArray(array $data): self { - return new self($data['shortUrl'], VisitsSummary::fromTotalAndNonBots( - (int) $data['visits'], - (int) $data['nonBotVisits'], - )); + return new self( + shortUrl: $data['shortUrl'], + visitsSummary: VisitsSummary::fromTotalAndNonBots( + total: (int) $data['visits'], + nonBots: (int) $data['nonBotVisits'], + ), + authority: $data['authority'] ?? null, + ); } public static function fromShortUrl(ShortUrl $shortUrl): self @@ -31,6 +38,6 @@ final readonly class ShortUrlWithVisitsSummary public function toArray(): array { - return $this->shortUrl->toArray($this->visitsSummary); + return $this->shortUrl->toArray($this->visitsSummary, fn() => $this->authority); } } diff --git a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php index e8fd4ac6..67d85b77 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php @@ -43,7 +43,7 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh $qb = $this->createListQueryBuilder($filtering); $qb->select( - 'DISTINCT s AS shortUrl', + 'DISTINCT s AS shortUrl, d.authority', '(' . $buildVisitsSubQuery('v', excludingBots: false) . ') AS ' . OrderableField::VISITS->value, '(' . $buildVisitsSubQuery('v2', excludingBots: true) . ') AS ' . OrderableField::NON_BOT_VISITS->value, // This is added only to have a consistent order by title between database engines @@ -89,6 +89,7 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh { $qb = $this->getEntityManager()->createQueryBuilder(); $qb->from(ShortUrl::class, 's') + ->leftJoin('s.domain', 'd') ->where('1=1'); $dateRange = $filtering->dateRange; @@ -129,8 +130,7 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh $conditions[] = $qb->expr()->like('t.name', ':searchPattern'); } - $qb->leftJoin('s.domain', 'd') - ->andWhere($qb->expr()->orX(...$conditions)) + $qb->andWhere($qb->expr()->orX(...$conditions)) ->setParameter('searchPattern', '%' . $searchTerm . '%'); } From d2403367b5715ad3d7df8e3690da9d4da525c342 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 27 Oct 2024 11:40:06 +0100 Subject: [PATCH 03/80] Fix PublishingUpdatesGeneratorTest --- .../test/EventDispatcher/PublishingUpdatesGeneratorTest.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php b/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php index 1ea76bf6..08b4cdb7 100644 --- a/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php +++ b/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php @@ -71,6 +71,7 @@ class PublishingUpdatesGeneratorTest extends TestCase 'crawlable' => false, 'forwardQuery' => true, 'visitsSummary' => VisitsSummary::fromTotalAndNonBots(0, 0), + 'hasRedirectRules' => false, ], 'visit' => [ 'referer' => '', @@ -145,6 +146,7 @@ class PublishingUpdatesGeneratorTest extends TestCase 'crawlable' => false, 'forwardQuery' => true, 'visitsSummary' => VisitsSummary::fromTotalAndNonBots(0, 0), + 'hasRedirectRules' => false, ]], $update->payload); } } From bf121c58baa26cb803ce2c325f564aeb5ad26b05 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 27 Oct 2024 12:26:34 +0100 Subject: [PATCH 04/80] Fix API tests --- module/Rest/test-api/Action/ListShortUrlsTest.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/module/Rest/test-api/Action/ListShortUrlsTest.php b/module/Rest/test-api/Action/ListShortUrlsTest.php index e3fc49a6..a17e5161 100644 --- a/module/Rest/test-api/Action/ListShortUrlsTest.php +++ b/module/Rest/test-api/Action/ListShortUrlsTest.php @@ -34,6 +34,7 @@ class ListShortUrlsTest extends ApiTestCase 'title' => 'My cool title', 'crawlable' => true, 'forwardQuery' => true, + 'hasRedirectRules' => false, ]; private const SHORT_URL_DOCS = [ 'shortCode' => 'ghi789', @@ -55,6 +56,7 @@ class ListShortUrlsTest extends ApiTestCase 'title' => null, 'crawlable' => false, 'forwardQuery' => true, + 'hasRedirectRules' => false, ]; private const SHORT_URL_CUSTOM_SLUG_AND_DOMAIN = [ 'shortCode' => 'custom-with-domain', @@ -76,6 +78,7 @@ class ListShortUrlsTest extends ApiTestCase 'title' => null, 'crawlable' => false, 'forwardQuery' => true, + 'hasRedirectRules' => false, ]; private const SHORT_URL_META = [ 'shortCode' => 'def456', @@ -99,6 +102,7 @@ class ListShortUrlsTest extends ApiTestCase 'title' => null, 'crawlable' => false, 'forwardQuery' => true, + 'hasRedirectRules' => true, ]; private const SHORT_URL_CUSTOM_SLUG = [ 'shortCode' => 'custom', @@ -120,6 +124,7 @@ class ListShortUrlsTest extends ApiTestCase 'title' => null, 'crawlable' => true, 'forwardQuery' => false, + 'hasRedirectRules' => false, ]; private const SHORT_URL_CUSTOM_DOMAIN = [ 'shortCode' => 'ghi789', @@ -143,6 +148,7 @@ class ListShortUrlsTest extends ApiTestCase 'title' => null, 'crawlable' => false, 'forwardQuery' => true, + 'hasRedirectRules' => false, ]; #[Test, DataProvider('provideFilteredLists')] From af569ad7a55fd1f10c21db8a3fabee88d4e4737f Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 27 Oct 2024 12:33:15 +0100 Subject: [PATCH 05/80] Fix PHPStan rules --- module/Core/src/ShortUrl/Entity/ShortUrl.php | 2 +- module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/module/Core/src/ShortUrl/Entity/ShortUrl.php b/module/Core/src/ShortUrl/Entity/ShortUrl.php index 35c0dfd2..ac50064c 100644 --- a/module/Core/src/ShortUrl/Entity/ShortUrl.php +++ b/module/Core/src/ShortUrl/Entity/ShortUrl.php @@ -265,7 +265,7 @@ class ShortUrl extends AbstractEntity } /** - * @param null|(callable(): string|null) $getAuthority - + * @param null|(callable(): ?string) $getAuthority - * This is a callback so that we trust its return value if provided, even if it is null. * Providing the raw authority as `string|null` would result in a fallback to `$this->domain` when the authority * was null. diff --git a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php index 67d85b77..8c49697a 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php @@ -56,7 +56,7 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh $this->processOrderByForList($qb, $filtering); - /** @var array{shortUrl: ShortUrl, visits: string, nonBotVisits: string}[] $result */ + /** @var array{shortUrl: ShortUrl, visits: string, nonBotVisits: string, authority: string|null}[] $result */ $result = $qb->getQuery()->getResult(); return map($result, static fn (array $s) => ShortUrlWithVisitsSummary::fromArray($s)); } From ac2e2497469c014a40cbd0590bcd116f32ec58fe Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 27 Oct 2024 12:33:47 +0100 Subject: [PATCH 06/80] Update swagger Short URL examples to include forwardQuery and hasRedirectRules --- docs/swagger/paths/v1_short-urls.json | 16 ++++++++++++---- docs/swagger/paths/v1_short-urls_shorten.json | 4 +++- .../swagger/paths/v1_short-urls_{shortCode}.json | 8 ++++++-- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/docs/swagger/paths/v1_short-urls.json b/docs/swagger/paths/v1_short-urls.json index 7d172ff4..89bdaaf4 100644 --- a/docs/swagger/paths/v1_short-urls.json +++ b/docs/swagger/paths/v1_short-urls.json @@ -180,7 +180,9 @@ }, "domain": null, "title": "Welcome to Steam", - "crawlable": false + "crawlable": false, + "forwardQuery": true, + "hasRedirectRules": true }, { "shortCode": "12Kb3", @@ -202,7 +204,9 @@ }, "domain": null, "title": null, - "crawlable": false + "crawlable": false, + "forwardQuery": true, + "hasRedirectRules": false }, { "shortCode": "123bA", @@ -222,7 +226,9 @@ }, "domain": "example.com", "title": null, - "crawlable": false + "crawlable": false, + "forwardQuery": false, + "hasRedirectRules": true } ], "pagination": { @@ -337,7 +343,9 @@ }, "domain": null, "title": null, - "crawlable": false + "crawlable": false, + "forwardQuery": true, + "hasRedirectRules": false } } } diff --git a/docs/swagger/paths/v1_short-urls_shorten.json b/docs/swagger/paths/v1_short-urls_shorten.json index 1136aca1..17b6f97f 100644 --- a/docs/swagger/paths/v1_short-urls_shorten.json +++ b/docs/swagger/paths/v1_short-urls_shorten.json @@ -72,7 +72,9 @@ }, "domain": null, "title": null, - "crawlable": false + "crawlable": false, + "forwardQuery": true, + "hasRedirectRules": false } }, "text/plain": { diff --git a/docs/swagger/paths/v1_short-urls_{shortCode}.json b/docs/swagger/paths/v1_short-urls_{shortCode}.json index c1a6eafc..11c1e0a7 100644 --- a/docs/swagger/paths/v1_short-urls_{shortCode}.json +++ b/docs/swagger/paths/v1_short-urls_{shortCode}.json @@ -50,7 +50,9 @@ }, "domain": null, "title": null, - "crawlable": false + "crawlable": false, + "forwardQuery": true, + "hasRedirectRules": true } } } @@ -163,7 +165,9 @@ }, "domain": null, "title": "Shlink - The URL shortener", - "crawlable": false + "crawlable": false, + "forwardQuery": false, + "hasRedirectRules": true } } } From 1dd71d2ee7021083896bef3ce63cdb966442f60b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 27 Oct 2024 12:35:26 +0100 Subject: [PATCH 07/80] Update changelog --- CHANGELOG.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7500a9a..380c6526 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,26 @@ 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 +* [#2207](https://github.com/shlinkio/shlink/issues/2207) Add `hasRedirectRules` flag to short URL API model. This flag tells if a specific short URL has any redirect rules attached to it. +* [#1520](https://github.com/shlinkio/shlink/issues/1520) Allow short URLs list to be filtered by `domain`. + + This change applies both to the `GET /short-urls` endpoint, via the `domain` query parameter, and the `short-url:list` console command, via the `--domain`|`-d` flag. + +### Changed +* Update to Shlink PHP coding standard 2.4 + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* *Nothing* + + ## [4.2.5] - 2024-11-03 ### Added * *Nothing* From 525a306ec630fa9f28e882fa3583a3089cc9eee7 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 28 Oct 2024 08:36:06 +0100 Subject: [PATCH 08/80] Create constant representing default domain identifier --- .../src/Command/ShortUrl/ListShortUrlsCommand.php | 3 ++- module/CLI/test-cli/Command/CreateShortUrlTest.php | 3 ++- module/CLI/test-cli/Command/ImportShortUrlsTest.php | 5 +++-- module/Core/src/Domain/Entity/Domain.php | 2 ++ .../ShortUrl/Persistence/ShortUrlsCountFiltering.php | 1 + .../ShortUrl/Repository/ShortUrlListRepository.php | 6 ++++-- module/Core/src/Visit/Repository/VisitRepository.php | 3 ++- module/Core/src/Visit/VisitsStatsHelper.php | 2 +- .../test-db/Visit/Repository/VisitRepositoryTest.php | 12 ++++++------ module/Core/test/Visit/VisitsStatsHelperTest.php | 6 +++--- module/Rest/src/Action/Visit/DomainVisitsAction.php | 3 ++- module/Rest/test-api/Action/DomainVisitsTest.php | 11 ++++++----- .../test/Action/Visit/DomainVisitsActionTest.php | 5 +++-- 13 files changed, 37 insertions(+), 25 deletions(-) diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index 6d38ff0f..9fd39d44 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -10,6 +10,7 @@ use Shlinkio\Shlink\CLI\Util\ExitCode; use Shlinkio\Shlink\CLI\Util\ShlinkTable; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtils; +use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary; @@ -231,7 +232,7 @@ class ListShortUrlsCommand extends Command } if ($input->getOption('show-domain')) { $columnsMap['Domain'] = static fn (array $_, ShortUrl $shortUrl): string => - $shortUrl->getDomain()?->authority ?? 'DEFAULT'; + $shortUrl->getDomain()?->authority ?? Domain::DEFAULT_AUTHORITY; } if ($input->getOption('show-api-key')) { $columnsMap['API Key'] = static fn (array $_, ShortUrl $shortUrl): string => diff --git a/module/CLI/test-cli/Command/CreateShortUrlTest.php b/module/CLI/test-cli/Command/CreateShortUrlTest.php index c2e96611..b07975be 100644 --- a/module/CLI/test-cli/Command/CreateShortUrlTest.php +++ b/module/CLI/test-cli/Command/CreateShortUrlTest.php @@ -8,6 +8,7 @@ use PHPUnit\Framework\Attributes\Test; use Shlinkio\Shlink\CLI\Command\ShortUrl\CreateShortUrlCommand; use Shlinkio\Shlink\CLI\Command\ShortUrl\ListShortUrlsCommand; use Shlinkio\Shlink\CLI\Util\ExitCode; +use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\TestUtils\CliTest\CliTestCase; class CreateShortUrlTest extends CliTestCase @@ -26,6 +27,6 @@ class CreateShortUrlTest extends CliTestCase self::assertStringContainsString('Generated short URL: http://' . $defaultDomain . '/' . $slug, $output); [$listOutput] = $this->exec([ListShortUrlsCommand::NAME, '--show-domain', '--search-term', $slug]); - self::assertStringContainsString('DEFAULT', $listOutput); + self::assertStringContainsString(Domain::DEFAULT_AUTHORITY, $listOutput); } } diff --git a/module/CLI/test-cli/Command/ImportShortUrlsTest.php b/module/CLI/test-cli/Command/ImportShortUrlsTest.php index 1ed15d7c..40e00cc0 100644 --- a/module/CLI/test-cli/Command/ImportShortUrlsTest.php +++ b/module/CLI/test-cli/Command/ImportShortUrlsTest.php @@ -6,6 +6,7 @@ namespace ShlinkioCliTest\Shlink\CLI\Command; use PHPUnit\Framework\Attributes\Test; use Shlinkio\Shlink\CLI\Command\ShortUrl\ListShortUrlsCommand; +use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Importer\Command\ImportCommand; use Shlinkio\Shlink\TestUtils\CliTest\CliTestCase; @@ -66,10 +67,10 @@ class ImportShortUrlsTest extends CliTestCase [$listOutput1] = $this->exec( [ListShortUrlsCommand::NAME, '--show-domain', '--search-term', 'testing-default-domain-import-1'], ); - self::assertStringContainsString('DEFAULT', $listOutput1); + self::assertStringContainsString(Domain::DEFAULT_AUTHORITY, $listOutput1); [$listOutput1] = $this->exec( [ListShortUrlsCommand::NAME, '--show-domain', '--search-term', 'testing-default-domain-import-2'], ); - self::assertStringContainsString('DEFAULT', $listOutput1); + self::assertStringContainsString(Domain::DEFAULT_AUTHORITY, $listOutput1); } } diff --git a/module/Core/src/Domain/Entity/Domain.php b/module/Core/src/Domain/Entity/Domain.php index b3d2b734..ba3446a7 100644 --- a/module/Core/src/Domain/Entity/Domain.php +++ b/module/Core/src/Domain/Entity/Domain.php @@ -11,6 +11,8 @@ use Shlinkio\Shlink\Core\Config\NotFoundRedirects; class Domain extends AbstractEntity implements JsonSerializable, NotFoundRedirectConfigInterface { + public const DEFAULT_AUTHORITY = 'DEFAULT'; + private function __construct( public readonly string $authority, private ?string $baseUrlRedirect = null, diff --git a/module/Core/src/ShortUrl/Persistence/ShortUrlsCountFiltering.php b/module/Core/src/ShortUrl/Persistence/ShortUrlsCountFiltering.php index 906adc63..15b9d47f 100644 --- a/module/Core/src/ShortUrl/Persistence/ShortUrlsCountFiltering.php +++ b/module/Core/src/ShortUrl/Persistence/ShortUrlsCountFiltering.php @@ -25,6 +25,7 @@ class ShortUrlsCountFiltering public readonly bool $excludePastValidUntil = false, public readonly ?ApiKey $apiKey = null, ?string $defaultDomain = null, + public readonly ?string $domain = null, ) { $this->searchIncludesDefaultDomain = !empty($searchTerm) && !empty($defaultDomain) && str_contains( strtolower($defaultDomain), diff --git a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php index 8c49697a..70e9dbff 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php @@ -104,14 +104,13 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh $searchTerm = $filtering->searchTerm; $tags = $filtering->tags; - // Apply search term to every searchable field if not empty if (! empty($searchTerm)) { // Left join with tags only if no tags were provided. In case of tags, an inner join will be done later if (empty($tags)) { $qb->leftJoin('s.tags', 't'); } - // Apply general search conditions + // Apply search term to every "searchable" field $conditions = [ $qb->expr()->like('s.longUrl', ':searchPattern'), $qb->expr()->like('s.shortCode', ':searchPattern'), @@ -142,6 +141,9 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh : $this->joinAllTags($qb, $tags); } + if ($filtering->domain !== null) { + } + if ($filtering->excludeMaxVisitsReached) { $qb->andWhere($qb->expr()->orX( $qb->expr()->isNull('s.maxVisits'), diff --git a/module/Core/src/Visit/Repository/VisitRepository.php b/module/Core/src/Visit/Repository/VisitRepository.php index 0708a4e1..1df109b3 100644 --- a/module/Core/src/Visit/Repository/VisitRepository.php +++ b/module/Core/src/Visit/Repository/VisitRepository.php @@ -8,6 +8,7 @@ use Doctrine\ORM\Query\ResultSetMappingBuilder; use Doctrine\ORM\QueryBuilder; use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository; use Shlinkio\Shlink\Common\Util\DateRange; +use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepositoryInterface; @@ -124,7 +125,7 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo $qb->from(Visit::class, 'v') ->join('v.shortUrl', 's'); - if ($domain === 'DEFAULT') { + if ($domain === Domain::DEFAULT_AUTHORITY) { $qb->where($qb->expr()->isNull('s.domain')); } else { $qb->join('s.domain', 'd') diff --git a/module/Core/src/Visit/VisitsStatsHelper.php b/module/Core/src/Visit/VisitsStatsHelper.php index 7f3e2282..0952670b 100644 --- a/module/Core/src/Visit/VisitsStatsHelper.php +++ b/module/Core/src/Visit/VisitsStatsHelper.php @@ -109,7 +109,7 @@ readonly class VisitsStatsHelper implements VisitsStatsHelperInterface { /** @var DomainRepository $domainRepo */ $domainRepo = $this->em->getRepository(Domain::class); - if ($domain !== 'DEFAULT' && ! $domainRepo->domainExists($domain, $apiKey)) { + if ($domain !== Domain::DEFAULT_AUTHORITY && ! $domainRepo->domainExists($domain, $apiKey)) { throw DomainNotFoundException::fromAuthority($domain); } diff --git a/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php b/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php index 9dc18390..8d7579b7 100644 --- a/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php +++ b/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php @@ -227,7 +227,7 @@ class VisitRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); self::assertCount(0, $this->repo->findVisitsByDomain('invalid', new VisitsListFiltering())); - self::assertCount(6, $this->repo->findVisitsByDomain('DEFAULT', new VisitsListFiltering())); + self::assertCount(6, $this->repo->findVisitsByDomain(Domain::DEFAULT_AUTHORITY, new VisitsListFiltering())); self::assertCount(3, $this->repo->findVisitsByDomain('s.test', new VisitsListFiltering())); self::assertCount(1, $this->repo->findVisitsByDomain('s.test', new VisitsListFiltering(null, true))); self::assertCount(2, $this->repo->findVisitsByDomain('s.test', new VisitsListFiltering( @@ -236,10 +236,10 @@ class VisitRepositoryTest extends DatabaseTestCase self::assertCount(1, $this->repo->findVisitsByDomain('s.test', new VisitsListFiltering( DateRange::since(Chronos::parse('2016-01-03')), ))); - self::assertCount(2, $this->repo->findVisitsByDomain('DEFAULT', new VisitsListFiltering( + self::assertCount(2, $this->repo->findVisitsByDomain(Domain::DEFAULT_AUTHORITY, new VisitsListFiltering( DateRange::between(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')), ))); - self::assertCount(4, $this->repo->findVisitsByDomain('DEFAULT', new VisitsListFiltering( + self::assertCount(4, $this->repo->findVisitsByDomain(Domain::DEFAULT_AUTHORITY, new VisitsListFiltering( DateRange::since(Chronos::parse('2016-01-03')), ))); } @@ -251,7 +251,7 @@ class VisitRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); self::assertEquals(0, $this->repo->countVisitsByDomain('invalid', new VisitsListFiltering())); - self::assertEquals(6, $this->repo->countVisitsByDomain('DEFAULT', new VisitsListFiltering())); + self::assertEquals(6, $this->repo->countVisitsByDomain(Domain::DEFAULT_AUTHORITY, new VisitsListFiltering())); self::assertEquals(3, $this->repo->countVisitsByDomain('s.test', new VisitsListFiltering())); self::assertEquals(1, $this->repo->countVisitsByDomain('s.test', new VisitsListFiltering(null, true))); self::assertEquals(2, $this->repo->countVisitsByDomain('s.test', new VisitsListFiltering( @@ -260,10 +260,10 @@ class VisitRepositoryTest extends DatabaseTestCase self::assertEquals(1, $this->repo->countVisitsByDomain('s.test', new VisitsListFiltering( DateRange::since(Chronos::parse('2016-01-03')), ))); - self::assertEquals(2, $this->repo->countVisitsByDomain('DEFAULT', new VisitsListFiltering( + self::assertEquals(2, $this->repo->countVisitsByDomain(Domain::DEFAULT_AUTHORITY, new VisitsListFiltering( DateRange::between(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')), ))); - self::assertEquals(4, $this->repo->countVisitsByDomain('DEFAULT', new VisitsListFiltering( + self::assertEquals(4, $this->repo->countVisitsByDomain(Domain::DEFAULT_AUTHORITY, new VisitsListFiltering( DateRange::since(Chronos::parse('2016-01-03')), ))); } diff --git a/module/Core/test/Visit/VisitsStatsHelperTest.php b/module/Core/test/Visit/VisitsStatsHelperTest.php index c1aa0747..61fb1293 100644 --- a/module/Core/test/Visit/VisitsStatsHelperTest.php +++ b/module/Core/test/Visit/VisitsStatsHelperTest.php @@ -240,11 +240,11 @@ class VisitsStatsHelperTest extends TestCase ); $repo2 = $this->createMock(VisitRepository::class); $repo2->method('findVisitsByDomain')->with( - 'DEFAULT', + Domain::DEFAULT_AUTHORITY, $this->isInstanceOf(VisitsListFiltering::class), )->willReturn($list); $repo2->method('countVisitsByDomain')->with( - 'DEFAULT', + Domain::DEFAULT_AUTHORITY, $this->isInstanceOf(VisitsCountFiltering::class), )->willReturn(1); @@ -253,7 +253,7 @@ class VisitsStatsHelperTest extends TestCase [Visit::class, $repo2], ]); - $paginator = $this->helper->visitsForDomain('DEFAULT', new VisitsParams(), $apiKey); + $paginator = $this->helper->visitsForDomain(Domain::DEFAULT_AUTHORITY, new VisitsParams(), $apiKey); self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentPageResults())); } diff --git a/module/Rest/src/Action/Visit/DomainVisitsAction.php b/module/Rest/src/Action/Visit/DomainVisitsAction.php index 4d534202..fc9cf20c 100644 --- a/module/Rest/src/Action/Visit/DomainVisitsAction.php +++ b/module/Rest/src/Action/Visit/DomainVisitsAction.php @@ -9,6 +9,7 @@ use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtils; use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions; +use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; @@ -39,7 +40,7 @@ class DomainVisitsAction extends AbstractRestAction { $domainParam = $request->getAttribute('domain', ''); if ($domainParam === $this->urlShortenerOptions->defaultDomain) { - return 'DEFAULT'; + return Domain::DEFAULT_AUTHORITY; } return $domainParam; diff --git a/module/Rest/test-api/Action/DomainVisitsTest.php b/module/Rest/test-api/Action/DomainVisitsTest.php index 3a06257b..628b7211 100644 --- a/module/Rest/test-api/Action/DomainVisitsTest.php +++ b/module/Rest/test-api/Action/DomainVisitsTest.php @@ -7,6 +7,7 @@ namespace ShlinkioApiTest\Shlink\Rest\Action; use GuzzleHttp\RequestOptions; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; +use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; use function sprintf; @@ -34,11 +35,11 @@ class DomainVisitsTest extends ApiTestCase public static function provideDomains(): iterable { yield 'example.com with admin API key' => ['valid_api_key', 'example.com', false, 0]; - yield 'DEFAULT with admin API key' => ['valid_api_key', 'DEFAULT', false, 7]; - yield 'DEFAULT with admin API key and no bots' => ['valid_api_key', 'DEFAULT', true, 6]; - yield 'DEFAULT with domain API key' => ['domain_api_key', 'DEFAULT', false, 0]; - yield 'DEFAULT with author API key' => ['author_api_key', 'DEFAULT', false, 5]; - yield 'DEFAULT with author API key and no bots' => ['author_api_key', 'DEFAULT', true, 4]; + yield 'DEFAULT with admin API key' => ['valid_api_key', Domain::DEFAULT_AUTHORITY, false, 7]; + yield 'DEFAULT with admin API key and no bots' => ['valid_api_key', Domain::DEFAULT_AUTHORITY, true, 6]; + yield 'DEFAULT with domain API key' => ['domain_api_key', Domain::DEFAULT_AUTHORITY, false, 0]; + yield 'DEFAULT with author API key' => ['author_api_key', Domain::DEFAULT_AUTHORITY, false, 5]; + yield 'DEFAULT with author API key and no bots' => ['author_api_key', Domain::DEFAULT_AUTHORITY, true, 4]; } #[Test, DataProvider('provideApiKeysAndTags')] diff --git a/module/Rest/test/Action/Visit/DomainVisitsActionTest.php b/module/Rest/test/Action/Visit/DomainVisitsActionTest.php index d60dae2e..d4a16573 100644 --- a/module/Rest/test/Action/Visit/DomainVisitsActionTest.php +++ b/module/Rest/test/Action/Visit/DomainVisitsActionTest.php @@ -12,6 +12,7 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions; +use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; use Shlinkio\Shlink\Rest\Action\Visit\DomainVisitsAction; @@ -49,7 +50,7 @@ class DomainVisitsActionTest extends TestCase public static function provideDomainAuthorities(): iterable { yield 'no default domain' => ['foo.com', 'foo.com']; - yield 'default domain' => ['the_default.com', 'DEFAULT']; - yield 'DEFAULT keyword' => ['DEFAULT', 'DEFAULT']; + yield 'default domain' => ['the_default.com', Domain::DEFAULT_AUTHORITY]; + yield 'DEFAULT keyword' => ['DEFAULT', Domain::DEFAULT_AUTHORITY]; } } From bb270396b6f7a0617c49bea762f4c13aa05ae8fc Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 28 Oct 2024 09:27:33 +0100 Subject: [PATCH 09/80] Allow short URLs list to be filtered by domain authority --- docs/swagger/paths/v1_short-urls.json | 9 +++ .../ShortUrl/ListShortUrlsCommandTest.php | 4 +- .../src/ShortUrl/Model/ShortUrlsParams.php | 6 +- .../Validation/ShortUrlsParamsInputFilter.php | 3 + .../Persistence/ShortUrlsCountFiltering.php | 1 + .../Persistence/ShortUrlsListFiltering.php | 4 ++ .../Repository/ShortUrlListRepository.php | 11 ++- .../Repository/ShortUrlListRepositoryTest.php | 68 ++++++++++++++----- .../test/ShortUrl/ShortUrlListServiceTest.php | 2 +- 9 files changed, 83 insertions(+), 25 deletions(-) diff --git a/docs/swagger/paths/v1_short-urls.json b/docs/swagger/paths/v1_short-urls.json index 89bdaaf4..6ca05c2e 100644 --- a/docs/swagger/paths/v1_short-urls.json +++ b/docs/swagger/paths/v1_short-urls.json @@ -125,6 +125,15 @@ "false" ] } + }, + { + "name": "domain", + "in": "query", + "description": "Get short URLs for this particular domain only. Use **DEFAULT** keyword for default domain.", + "required": false, + "schema": { + "type": "string" + } } ], "security": [ diff --git a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php index 176800ab..c1a3ab23 100644 --- a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php @@ -74,7 +74,7 @@ class ListShortUrlsCommandTest extends TestCase } $this->shortUrlService->expects($this->once())->method('listShortUrls')->with( - ShortUrlsParams::emptyInstance(), + ShortUrlsParams::empty(), )->willReturn(new Paginator(new ArrayAdapter($data))); $this->commandTester->setInputs(['n']); @@ -110,7 +110,7 @@ class ListShortUrlsCommandTest extends TestCase ApiKey $apiKey, ): void { $this->shortUrlService->expects($this->once())->method('listShortUrls')->with( - ShortUrlsParams::emptyInstance(), + ShortUrlsParams::empty(), )->willReturn(new Paginator(new ArrayAdapter([ ShortUrlWithVisitsSummary::fromShortUrl( ShortUrl::create(ShortUrlCreation::fromRawData([ diff --git a/module/Core/src/ShortUrl/Model/ShortUrlsParams.php b/module/Core/src/ShortUrl/Model/ShortUrlsParams.php index 88e20aa7..e625087e 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlsParams.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlsParams.php @@ -19,17 +19,18 @@ final class ShortUrlsParams private function __construct( public readonly int $page, public readonly int $itemsPerPage, - public readonly ?string $searchTerm, + public readonly string|null $searchTerm, public readonly array $tags, public readonly Ordering $orderBy, public readonly ?DateRange $dateRange, public readonly bool $excludeMaxVisitsReached, public readonly bool $excludePastValidUntil, public readonly TagsMode $tagsMode = TagsMode::ANY, + public readonly string|null $domain = null, ) { } - public static function emptyInstance(): self + public static function empty(): self { return self::fromRawData([]); } @@ -59,6 +60,7 @@ final class ShortUrlsParams excludeMaxVisitsReached: $inputFilter->getValue(ShortUrlsParamsInputFilter::EXCLUDE_MAX_VISITS_REACHED), excludePastValidUntil: $inputFilter->getValue(ShortUrlsParamsInputFilter::EXCLUDE_PAST_VALID_UNTIL), tagsMode: self::resolveTagsMode($inputFilter->getValue(ShortUrlsParamsInputFilter::TAGS_MODE)), + domain: $inputFilter->getValue(ShortUrlsParamsInputFilter::DOMAIN), ); } diff --git a/module/Core/src/ShortUrl/Model/Validation/ShortUrlsParamsInputFilter.php b/module/Core/src/ShortUrl/Model/Validation/ShortUrlsParamsInputFilter.php index 0a0d45ed..600ebc33 100644 --- a/module/Core/src/ShortUrl/Model/Validation/ShortUrlsParamsInputFilter.php +++ b/module/Core/src/ShortUrl/Model/Validation/ShortUrlsParamsInputFilter.php @@ -26,6 +26,7 @@ class ShortUrlsParamsInputFilter extends InputFilter public const ORDER_BY = 'orderBy'; public const EXCLUDE_MAX_VISITS_REACHED = 'excludeMaxVisitsReached'; public const EXCLUDE_PAST_VALID_UNTIL = 'excludePastValidUntil'; + public const DOMAIN = 'domain'; public function __construct(array $data) { @@ -56,5 +57,7 @@ class ShortUrlsParamsInputFilter extends InputFilter $this->add(InputFactory::boolean(self::EXCLUDE_MAX_VISITS_REACHED)); $this->add(InputFactory::boolean(self::EXCLUDE_PAST_VALID_UNTIL)); + + $this->add(InputFactory::basic(self::DOMAIN)); } } diff --git a/module/Core/src/ShortUrl/Persistence/ShortUrlsCountFiltering.php b/module/Core/src/ShortUrl/Persistence/ShortUrlsCountFiltering.php index 15b9d47f..b27fe7c5 100644 --- a/module/Core/src/ShortUrl/Persistence/ShortUrlsCountFiltering.php +++ b/module/Core/src/ShortUrl/Persistence/ShortUrlsCountFiltering.php @@ -44,6 +44,7 @@ class ShortUrlsCountFiltering $params->excludePastValidUntil, $apiKey, $defaultDomain, + $params->domain, ); } } diff --git a/module/Core/src/ShortUrl/Persistence/ShortUrlsListFiltering.php b/module/Core/src/ShortUrl/Persistence/ShortUrlsListFiltering.php index 589947dd..b3946ab1 100644 --- a/module/Core/src/ShortUrl/Persistence/ShortUrlsListFiltering.php +++ b/module/Core/src/ShortUrl/Persistence/ShortUrlsListFiltering.php @@ -23,7 +23,9 @@ class ShortUrlsListFiltering extends ShortUrlsCountFiltering bool $excludeMaxVisitsReached = false, bool $excludePastValidUntil = false, ?ApiKey $apiKey = null, + // Used only to determine if search term includes default domain ?string $defaultDomain = null, + ?string $domain = null, ) { parent::__construct( $searchTerm, @@ -34,6 +36,7 @@ class ShortUrlsListFiltering extends ShortUrlsCountFiltering $excludePastValidUntil, $apiKey, $defaultDomain, + $domain, ); } @@ -56,6 +59,7 @@ class ShortUrlsListFiltering extends ShortUrlsCountFiltering $params->excludePastValidUntil, $apiKey, $defaultDomain, + $params->domain, ); } } diff --git a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php index 70e9dbff..6749a03f 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php @@ -9,6 +9,7 @@ use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository; use Shlinkio\Shlink\Common\Doctrine\Type\ChronosDateTimeType; +use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\OrderableField; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary; @@ -118,8 +119,8 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh $qb->expr()->like('d.authority', ':searchPattern'), ]; - // Include default domain in search if provided - if ($filtering->searchIncludesDefaultDomain) { + // Include default domain in search if included, and a domain was not explicitly provided + if ($filtering->searchIncludesDefaultDomain && $filtering->domain === null) { $conditions[] = $qb->expr()->isNull('s.domain'); } @@ -142,6 +143,12 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh } if ($filtering->domain !== null) { + if ($filtering->domain === Domain::DEFAULT_AUTHORITY) { + $qb->andWhere($qb->expr()->isNull('s.domain')); + } else { + $qb->andWhere($qb->expr()->eq('d.authority', ':domain')) + ->setParameter('domain', $filtering->domain); + } } if ($filtering->excludeMaxVisitsReached) { diff --git a/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php b/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php index 95924956..995f7218 100644 --- a/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php +++ b/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php @@ -9,6 +9,7 @@ use Doctrine\Common\Collections\ArrayCollection; use PHPUnit\Framework\Attributes\Test; use ReflectionObject; use Shlinkio\Shlink\Common\Util\DateRange; +use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\Model\Ordering; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\OrderableField; @@ -261,16 +262,23 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); - $buildFiltering = static fn (string $searchTerm) => new ShortUrlsListFiltering( + $buildFiltering = static fn (string $searchTerm = '', string|null $domain = null) => new ShortUrlsListFiltering( searchTerm: $searchTerm, defaultDomain: 'deFaulT-domain.com', + domain: $domain, ); - self::assertCount(2, $this->repo->findList($buildFiltering('default-dom'))); - self::assertCount(2, $this->repo->findList($buildFiltering('DOM'))); - self::assertCount(1, $this->repo->findList($buildFiltering('another'))); - self::assertCount(3, $this->repo->findList($buildFiltering('foo'))); - self::assertCount(0, $this->repo->findList($buildFiltering('no results'))); + self::assertCount(2, $this->repo->findList($buildFiltering(searchTerm: 'default-dom'))); + self::assertCount(2, $this->repo->findList($buildFiltering(searchTerm: 'DOM'))); + self::assertCount(1, $this->repo->findList($buildFiltering(searchTerm: 'another'))); + self::assertCount(3, $this->repo->findList($buildFiltering(searchTerm: 'foo'))); + self::assertCount(0, $this->repo->findList($buildFiltering(searchTerm: 'no results'))); + self::assertCount(1, $this->repo->findList($buildFiltering(domain: 'another.com'))); + self::assertCount(0, $this->repo->findList($buildFiltering( + searchTerm: 'default-domain.com', + domain: 'another.com', + ))); + self::assertCount(2, $this->repo->findList($buildFiltering(domain: Domain::DEFAULT_AUTHORITY))); } #[Test] @@ -303,18 +311,42 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); $filtering = static fn (bool $excludeMaxVisitsReached, bool $excludePastValidUntil) => - new ShortUrlsListFiltering( - excludeMaxVisitsReached: $excludeMaxVisitsReached, - excludePastValidUntil: $excludePastValidUntil, - ); + new ShortUrlsListFiltering( + excludeMaxVisitsReached: $excludeMaxVisitsReached, + excludePastValidUntil: $excludePastValidUntil, + ); - self::assertCount(4, $this->repo->findList($filtering(false, false))); - self::assertEquals(4, $this->repo->countList($filtering(false, false))); - self::assertCount(3, $this->repo->findList($filtering(true, false))); - self::assertEquals(3, $this->repo->countList($filtering(true, false))); - self::assertCount(3, $this->repo->findList($filtering(false, true))); - self::assertEquals(3, $this->repo->countList($filtering(false, true))); - self::assertCount(2, $this->repo->findList($filtering(true, true))); - self::assertEquals(2, $this->repo->countList($filtering(true, true))); + self::assertCount(4, $this->repo->findList($filtering( + excludeMaxVisitsReached: false, + excludePastValidUntil: false, + ))); + self::assertEquals(4, $this->repo->countList($filtering( + excludeMaxVisitsReached: false, + excludePastValidUntil: false, + ))); + self::assertCount(3, $this->repo->findList($filtering( + excludeMaxVisitsReached: true, + excludePastValidUntil: false, + ))); + self::assertEquals(3, $this->repo->countList($filtering( + excludeMaxVisitsReached: true, + excludePastValidUntil: false, + ))); + self::assertCount(3, $this->repo->findList($filtering( + excludeMaxVisitsReached: false, + excludePastValidUntil: true, + ))); + self::assertEquals(3, $this->repo->countList($filtering( + excludeMaxVisitsReached: false, + excludePastValidUntil: true, + ))); + self::assertCount(2, $this->repo->findList($filtering( + excludeMaxVisitsReached: true, + excludePastValidUntil: true, + ))); + self::assertEquals(2, $this->repo->countList($filtering( + excludeMaxVisitsReached: true, + excludePastValidUntil: true, + ))); } } diff --git a/module/Core/test/ShortUrl/ShortUrlListServiceTest.php b/module/Core/test/ShortUrl/ShortUrlListServiceTest.php index d8663761..2ae5c584 100644 --- a/module/Core/test/ShortUrl/ShortUrlListServiceTest.php +++ b/module/Core/test/ShortUrl/ShortUrlListServiceTest.php @@ -42,7 +42,7 @@ class ShortUrlListServiceTest extends TestCase $this->repo->expects($this->once())->method('findList')->willReturn($list); $this->repo->expects($this->once())->method('countList')->willReturn(count($list)); - $paginator = $this->service->listShortUrls(ShortUrlsParams::emptyInstance(), $apiKey); + $paginator = $this->service->listShortUrls(ShortUrlsParams::empty(), $apiKey); self::assertCount(4, $paginator); self::assertCount(4, $paginator->getCurrentPageResults()); From a10ca655a2aafe47b2f01dfe5d5036bf11faf83f Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 28 Oct 2024 22:04:01 +0100 Subject: [PATCH 10/80] Cover domain filtering in ListShortUrls API test --- module/Rest/test-api/Action/ListShortUrlsTest.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/module/Rest/test-api/Action/ListShortUrlsTest.php b/module/Rest/test-api/Action/ListShortUrlsTest.php index a17e5161..1eba6db8 100644 --- a/module/Rest/test-api/Action/ListShortUrlsTest.php +++ b/module/Rest/test-api/Action/ListShortUrlsTest.php @@ -8,6 +8,7 @@ use Cake\Chronos\Chronos; use GuzzleHttp\RequestOptions; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; +use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; use function count; @@ -264,6 +265,15 @@ class ListShortUrlsTest extends ApiTestCase yield [['searchTerm' => 'example.com'], [ self::SHORT_URL_CUSTOM_DOMAIN, ], 'valid_api_key']; + yield [['domain' => 'example.com'], [ + self::SHORT_URL_CUSTOM_DOMAIN, + ], 'valid_api_key']; + yield [['domain' => Domain::DEFAULT_AUTHORITY], [ + self::SHORT_URL_CUSTOM_SLUG, + self::SHORT_URL_META, + self::SHORT_URL_SHLINK_WITH_TITLE, + self::SHORT_URL_DOCS, + ], 'valid_api_key']; yield [[], [ self::SHORT_URL_CUSTOM_SLUG, self::SHORT_URL_META, From 93a277a94d1a5bfddc88cef6b22e70d760a410bc Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 28 Oct 2024 22:15:01 +0100 Subject: [PATCH 11/80] Allow short URLs to be filtered by domain from the command line --- .../Command/ShortUrl/ListShortUrlsCommand.php | 8 ++++++++ .../CLI/test-cli/Command/ListShortUrlsTest.php | 17 +++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index 9fd39d44..34ccd57f 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -65,6 +65,12 @@ class ListShortUrlsCommand extends Command InputOption::VALUE_REQUIRED, 'A query used to filter results by searching for it on the longUrl and shortCode fields.', ) + ->addOption( + 'domain', + 'd', + InputOption::VALUE_REQUIRED, + 'Used to filter results by domain. Use DEFAULT keyword to filter by default domain', + ) ->addOption( 'tags', 't', @@ -135,6 +141,7 @@ class ListShortUrlsCommand extends Command $page = (int) $input->getOption('page'); $searchTerm = $input->getOption('search-term'); + $domain = $input->getOption('domain'); $tags = $input->getOption('tags'); $tagsMode = $input->getOption('including-all-tags') === true ? TagsMode::ALL->value : TagsMode::ANY->value; $tags = ! empty($tags) ? explode(',', $tags) : []; @@ -146,6 +153,7 @@ class ListShortUrlsCommand extends Command $data = [ ShortUrlsParamsInputFilter::SEARCH_TERM => $searchTerm, + ShortUrlsParamsInputFilter::DOMAIN => $domain, ShortUrlsParamsInputFilter::TAGS => $tags, ShortUrlsParamsInputFilter::TAGS_MODE => $tagsMode, ShortUrlsParamsInputFilter::ORDER_BY => $orderBy, diff --git a/module/CLI/test-cli/Command/ListShortUrlsTest.php b/module/CLI/test-cli/Command/ListShortUrlsTest.php index 8d9c7278..d7c50912 100644 --- a/module/CLI/test-cli/Command/ListShortUrlsTest.php +++ b/module/CLI/test-cli/Command/ListShortUrlsTest.php @@ -70,6 +70,23 @@ class ListShortUrlsTest extends CliTestCase | custom-with-domain | | http://some-domain.com/custom-with-domain | https://google.com | 2018-10-20T00:00:00+00:00 | 0 | +--------------------+-------+-------------------------------------------+-------------------------------- Page 1 of 1 --------------------------------------------------------------+---------------------------+--------------+ OUTPUT]; + yield 'non-default domain' => [['--domain=example.com'], << [['-d DEFAULT'], << Date: Mon, 28 Oct 2024 22:27:30 +0100 Subject: [PATCH 12/80] Update to PHP coding standard 2.4.0 --- CHANGELOG.md | 2 +- composer.json | 2 +- .../src/Command/Config/ReadEnvVarCommand.php | 2 +- .../Command/Domain/DomainRedirectsCommand.php | 2 +- .../ShortUrl/CreateShortUrlCommand.php | 2 +- .../Command/ShortUrl/ListShortUrlsCommand.php | 4 +-- .../Visit/DownloadGeoLiteDbCommand.php | 2 +- .../GeolocationDbUpdateFailedException.php | 6 ++-- .../CLI/src/GeoLite/GeolocationDbUpdater.php | 14 +++++---- .../GeoLite/GeolocationDbUpdaterInterface.php | 4 +-- module/CLI/src/Input/DateOption.php | 2 +- module/CLI/src/Input/EndDateOption.php | 2 +- module/CLI/src/Input/ShortUrlDataOption.php | 2 +- .../CLI/src/Input/ShortUrlIdentifierInput.php | 2 +- module/CLI/src/Input/StartDateOption.php | 2 +- .../src/RedirectRule/RedirectRuleHandler.php | 6 ++-- .../RedirectRuleHandlerInterface.php | 2 +- module/CLI/src/Util/ProcessRunner.php | 2 +- module/CLI/src/Util/ShlinkTable.php | 8 +++-- .../Command/Api/InitialApiKeyCommandTest.php | 7 +++-- .../Domain/DomainRedirectsCommandTest.php | 2 +- .../ShortUrl/CreateShortUrlCommandTest.php | 8 +++-- .../ShortUrl/ListShortUrlsCommandTest.php | 10 +++---- ...GeolocationDbUpdateFailedExceptionTest.php | 4 +-- .../test/GeoLite/GeolocationDbUpdaterTest.php | 2 +- .../RedirectRule/RedirectRuleHandlerTest.php | 2 +- module/Core/functions/functions.php | 14 ++++----- module/Core/src/Action/Model/QrCodeParams.php | 2 +- .../Config/EmptyNotFoundRedirectConfig.php | 6 ++-- .../NotFoundRedirectConfigInterface.php | 6 ++-- .../src/Config/NotFoundRedirectResolver.php | 2 +- .../NotFoundRedirectResolverInterface.php | 2 +- module/Core/src/Config/NotFoundRedirects.php | 12 ++++---- .../Options/NotFoundRedirectOptions.php | 12 ++++---- .../Core/src/Config/Options/QrCodeOptions.php | 2 +- .../src/Config/Options/TrackingOptions.php | 2 +- module/Core/src/Domain/DomainService.php | 12 ++++---- .../src/Domain/DomainServiceInterface.php | 8 ++--- module/Core/src/Domain/Entity/Domain.php | 12 ++++---- .../Domain/Repository/DomainRepository.php | 10 +++---- .../Repository/DomainRepositoryInterface.php | 6 ++-- module/Core/src/Domain/Spec/IsDomain.php | 2 +- .../src/ErrorHandler/Model/NotFoundType.php | 2 +- .../ErrorHandler/NotFoundRedirectHandler.php | 2 +- .../ErrorHandler/NotFoundTemplateHandler.php | 2 +- .../Event/AbstractVisitEvent.php | 2 +- .../Core/src/EventDispatcher/LocateVisit.php | 2 +- .../PublishingUpdatesGenerator.php | 2 +- module/Core/src/EventDispatcher/Topic.php | 2 +- .../Exception/IpCannotBeLocatedException.php | 2 +- .../src/Exception/NonUniqueSlugException.php | 2 +- .../src/Exception/ValidationException.php | 4 +-- module/Core/src/Matomo/MatomoOptions.php | 6 ++-- module/Core/src/Matomo/MatomoVisitSender.php | 2 +- .../src/Matomo/MatomoVisitSenderInterface.php | 2 +- .../AbstractInfinitePaginableListParams.php | 6 ++-- module/Core/src/Model/DeviceType.php | 2 +- module/Core/src/Model/Ordering.php | 2 +- ...AbstractCacheableCountPaginatorAdapter.php | 2 +- .../RedirectRule/Entity/RedirectCondition.php | 2 +- .../src/ShortUrl/DeleteShortUrlService.php | 2 +- .../DeleteShortUrlServiceInterface.php | 2 +- module/Core/src/ShortUrl/Entity/ShortUrl.php | 30 +++++++++---------- .../Helper/ShortUrlRedirectionBuilder.php | 4 +-- .../ShortUrlRedirectionBuilderInterface.php | 2 +- .../Helper/ShortUrlTitleResolutionHelper.php | 4 +-- .../ExtraPathRedirectMiddleware.php | 2 +- .../src/ShortUrl/Model/ShortUrlCreation.php | 16 +++++----- .../src/ShortUrl/Model/ShortUrlEdition.php | 10 +++---- .../src/ShortUrl/Model/ShortUrlIdentifier.php | 4 +-- .../src/ShortUrl/Model/ShortUrlsParams.php | 4 +-- .../ShortUrl/Model/UrlShorteningResult.php | 2 +- .../Model/Validation/ShortUrlInputFilter.php | 2 +- .../Adapter/ShortUrlRepositoryAdapter.php | 2 +- .../Persistence/ShortUrlsCountFiltering.php | 14 ++++----- .../Persistence/ShortUrlsListFiltering.php | 18 +++++------ .../Repository/ShortUrlRepository.php | 23 +++++++------- .../ShortUrlRepositoryInterface.php | 15 ++++++---- .../PersistenceShortUrlRelationResolver.php | 2 +- .../ShortUrlRelationResolverInterface.php | 2 +- .../SimpleShortUrlRelationResolver.php | 2 +- .../Core/src/ShortUrl/ShortUrlListService.php | 2 +- .../ShortUrl/ShortUrlListServiceInterface.php | 2 +- module/Core/src/ShortUrl/ShortUrlResolver.php | 2 +- .../ShortUrl/ShortUrlResolverInterface.php | 2 +- module/Core/src/ShortUrl/ShortUrlService.php | 2 +- .../src/ShortUrl/ShortUrlServiceInterface.php | 2 +- .../src/ShortUrl/ShortUrlVisitsDeleter.php | 2 +- .../ShortUrlVisitsDeleterInterface.php | 2 +- .../src/ShortUrl/Spec/BelongsToApiKey.php | 2 +- .../src/ShortUrl/Spec/BelongsToDomain.php | 2 +- module/Core/src/ShortUrl/UrlShortener.php | 2 +- module/Core/src/Spec/InDateRange.php | 2 +- module/Core/src/Tag/Model/OrderableField.php | 2 +- module/Core/src/Tag/Model/TagInfo.php | 2 +- .../Core/src/Tag/Model/TagsListFiltering.php | 12 ++++---- module/Core/src/Tag/Model/TagsParams.php | 6 ++-- .../Adapter/AbstractTagsPaginatorAdapter.php | 2 +- .../Core/src/Tag/Repository/TagRepository.php | 4 +-- .../Tag/Repository/TagRepositoryInterface.php | 4 +-- module/Core/src/Tag/TagService.php | 8 ++--- module/Core/src/Tag/TagServiceInterface.php | 8 ++--- module/Core/src/Util/IpAddressUtils.php | 4 +-- module/Core/src/Visit/Entity/Visit.php | 22 ++++++++------ .../src/Visit/Model/OrphanVisitsParams.php | 8 ++--- module/Core/src/Visit/Model/Visitor.php | 4 +-- module/Core/src/Visit/Model/VisitsParams.php | 6 ++-- module/Core/src/Visit/Model/VisitsStats.php | 4 +-- .../Adapter/DomainVisitsPaginatorAdapter.php | 2 +- .../NonOrphanVisitsPaginatorAdapter.php | 2 +- .../Adapter/OrphanVisitsPaginatorAdapter.php | 2 +- .../ShortUrlVisitsPaginatorAdapter.php | 2 +- .../Adapter/TagVisitsPaginatorAdapter.php | 2 +- .../OrphanVisitsCountFiltering.php | 6 ++-- .../Persistence/OrphanVisitsListFiltering.php | 10 +++---- .../Persistence/VisitsCountFiltering.php | 4 +-- .../Visit/Persistence/VisitsListFiltering.php | 8 ++--- .../Repository/VisitIterationRepository.php | 2 +- .../VisitIterationRepositoryInterface.php | 5 +++- .../src/Visit/Repository/VisitRepository.php | 6 ++-- .../Repository/VisitRepositoryInterface.php | 2 +- module/Core/src/Visit/RequestTracker.php | 2 +- module/Core/src/Visit/VisitsDeleter.php | 2 +- .../Core/src/Visit/VisitsDeleterInterface.php | 2 +- module/Core/src/Visit/VisitsStatsHelper.php | 12 ++++---- .../src/Visit/VisitsStatsHelperInterface.php | 12 ++++---- .../Repository/DomainRepositoryTest.php | 4 +-- .../Repository/ShortUrlRepositoryTest.php | 2 +- .../Adapter/TagsPaginatorAdapterTest.php | 4 +-- .../Tag/Repository/TagRepositoryTest.php | 4 +-- .../Visit/Repository/VisitRepositoryTest.php | 2 +- module/Core/test/Action/QrCodeActionTest.php | 6 ++-- .../ShortUrlMethodsProcessorTest.php | 4 +-- module/Core/test/Domain/DomainServiceTest.php | 10 ++++--- .../test/EventDispatcher/LocateVisitTest.php | 2 +- .../Matomo/SendVisitToMatomoTest.php | 2 +- .../PublishingUpdatesGeneratorTest.php | 2 +- .../RabbitMq/NotifyVisitToRabbitMqTest.php | 2 +- .../EventDispatcher/UpdateGeoLiteDbTest.php | 2 +- .../Exception/NonUniqueSlugExceptionTest.php | 2 +- .../ShortUrlNotFoundExceptionTest.php | 2 +- .../Exception/ValidationExceptionTest.php | 2 +- .../Importer/ImportedLinksProcessorTest.php | 8 ++--- .../test/Matomo/MatomoTrackerBuilderTest.php | 2 +- .../test/Matomo/MatomoVisitSenderTest.php | 2 +- .../Entity/RedirectConditionTest.php | 6 ++-- .../ShortUrlRedirectionResolverTest.php | 2 +- .../test/ShortUrl/Entity/ShortUrlTest.php | 4 +-- .../Helper/ShortCodeUniquenessHelperTest.php | 2 +- .../Helper/ShortUrlRedirectionBuilderTest.php | 4 +-- .../Helper/ShortUrlStringifierTest.php | 2 +- .../ExtraPathRedirectMiddlewareTest.php | 6 ++-- .../ShortUrl/Model/ShortUrlCreationTest.php | 4 +-- .../Adapter/ShortUrlRepositoryAdapterTest.php | 14 ++++----- ...ersistenceShortUrlRelationResolverTest.php | 6 ++-- .../SimpleShortUrlRelationResolverTest.php | 2 +- .../test/ShortUrl/ShortUrlListServiceTest.php | 2 +- .../test/ShortUrl/ShortUrlResolverTest.php | 4 +-- .../test/ShortUrl/ShortUrlServiceTest.php | 2 +- module/Core/test/Tag/TagServiceTest.php | 8 ++--- .../test/Util/RedirectResponseHelperTest.php | 4 +-- module/Core/test/Visit/Entity/VisitTest.php | 7 +++-- .../ShortUrlVisitsPaginatorAdapterTest.php | 2 +- .../VisitsForTagPaginatorAdapterTest.php | 2 +- .../Core/test/Visit/VisitsStatsHelperTest.php | 10 +++---- module/Core/test/Visit/VisitsTrackerTest.php | 2 +- .../Domain/Request/DomainRedirectsRequest.php | 8 ++--- module/Rest/src/ApiKey/Model/ApiKeyMeta.php | 10 +++---- .../ApiKey/Repository/ApiKeyRepository.php | 4 +-- .../Repository/ApiKeyRepositoryInterface.php | 2 +- module/Rest/src/ApiKey/Role.php | 2 +- .../Spec/WithApiKeySpecsEnsuringJoin.php | 6 ++-- module/Rest/src/ConfigProvider.php | 2 +- module/Rest/src/Entity/ApiKey.php | 10 +++---- module/Rest/src/Service/ApiKeyCheckResult.php | 2 +- module/Rest/src/Service/ApiKeyService.php | 4 +-- .../src/Service/ApiKeyServiceInterface.php | 2 +- .../test-api/Action/CreateShortUrlTest.php | 12 ++++---- .../test-api/Action/DeleteShortUrlTest.php | 2 +- .../Rest/test-api/Action/EditShortUrlTest.php | 4 +-- .../test-api/Action/ResolveShortUrlTest.php | 2 +- .../test-api/Action/ShortUrlVisitsTest.php | 4 +-- .../Action/SingleStepCreateShortUrlTest.php | 4 +-- .../Rest/test-api/Fixtures/ApiKeyFixture.php | 2 +- module/Rest/test-api/Utils/UrlBuilder.php | 2 +- .../Request/DomainRedirectsRequestTest.php | 8 ++--- .../test/Action/MercureInfoActionTest.php | 2 +- .../ShortUrl/ListShortUrlsActionTest.php | 8 ++--- .../test/Action/Tag/DeleteTagsActionTest.php | 2 +- .../Middleware/CrossDomainMiddlewareTest.php | 2 +- ...ortUrlContentNegotiationMiddlewareTest.php | 2 +- .../Rest/test/Service/ApiKeyServiceTest.php | 6 ++-- 192 files changed, 465 insertions(+), 432 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 380c6526..58c396ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * *Nothing* ### Changed -* *Nothing* +* Update to Shlink PHP coding standard 2.4 ### Deprecated * *Nothing* diff --git a/composer.json b/composer.json index 9a3e067a..3dc9e10b 100644 --- a/composer.json +++ b/composer.json @@ -72,7 +72,7 @@ "phpunit/phpcov": "^10.0", "phpunit/phpunit": "^11.4", "roave/security-advisories": "dev-master", - "shlinkio/php-coding-standard": "~2.3.0", + "shlinkio/php-coding-standard": "~2.4.0", "shlinkio/shlink-test-utils": "^4.1.1", "symfony/var-dumper": "^7.1", "veewee/composer-run-parallel": "^1.4" diff --git a/module/CLI/src/Command/Config/ReadEnvVarCommand.php b/module/CLI/src/Command/Config/ReadEnvVarCommand.php index 1f436eeb..76ec36ae 100644 --- a/module/CLI/src/Command/Config/ReadEnvVarCommand.php +++ b/module/CLI/src/Command/Config/ReadEnvVarCommand.php @@ -26,7 +26,7 @@ class ReadEnvVarCommand extends Command /** @var Closure(string $envVar): mixed */ private readonly Closure $loadEnvVar; - public function __construct(?Closure $loadEnvVar = null) + public function __construct(Closure|null $loadEnvVar = null) { $this->loadEnvVar = $loadEnvVar ?? static fn (string $envVar) => EnvVars::from($envVar)->loadFromEnv(); parent::__construct(); diff --git a/module/CLI/src/Command/Domain/DomainRedirectsCommand.php b/module/CLI/src/Command/Domain/DomainRedirectsCommand.php index c2e5e60d..61e4a93b 100644 --- a/module/CLI/src/Command/Domain/DomainRedirectsCommand.php +++ b/module/CLI/src/Command/Domain/DomainRedirectsCommand.php @@ -74,7 +74,7 @@ class DomainRedirectsCommand extends Command $domainAuthority = $input->getArgument('domain'); $domain = $this->domainService->findByAuthority($domainAuthority); - $ask = static function (string $message, ?string $current) use ($io): ?string { + $ask = static function (string $message, string|null $current) use ($io): string|null { if ($current === null) { return $io->ask(sprintf('%s (Leave empty for no redirect)', $message)); } diff --git a/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php index fdff2ddc..b6fa5034 100644 --- a/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php @@ -22,7 +22,7 @@ class CreateShortUrlCommand extends Command { public const NAME = 'short-url:create'; - private ?SymfonyStyle $io; + private SymfonyStyle|null $io; private readonly ShortUrlDataInput $shortUrlDataInput; public function __construct( diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index 34ccd57f..d7243dfb 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -210,7 +210,7 @@ class ListShortUrlsCommand extends Command return $shortUrls; } - private function processOrderBy(InputInterface $input): ?string + private function processOrderBy(InputInterface $input): string|null { $orderBy = $input->getOption('order-by'); if (empty($orderBy)) { @@ -247,7 +247,7 @@ class ListShortUrlsCommand extends Command $shortUrl->authorApiKey?->__toString() ?? ''; } if ($input->getOption('show-api-key-name')) { - $columnsMap['API Key Name'] = static fn (array $_, ShortUrl $shortUrl): ?string => + $columnsMap['API Key Name'] = static fn (array $_, ShortUrl $shortUrl): string|null => $shortUrl->authorApiKey?->name; } diff --git a/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php b/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php index ac8ee102..41674a79 100644 --- a/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php +++ b/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php @@ -20,7 +20,7 @@ class DownloadGeoLiteDbCommand extends Command { public const NAME = 'visit:download-db'; - private ?ProgressBar $progressBar = null; + private ProgressBar|null $progressBar = null; public function __construct(private GeolocationDbUpdaterInterface $dbUpdater) { diff --git a/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php b/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php index ceb5cbfd..ee31ac82 100644 --- a/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php +++ b/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php @@ -13,12 +13,12 @@ class GeolocationDbUpdateFailedException extends RuntimeException implements Exc { private bool $olderDbExists; - private function __construct(string $message, ?Throwable $previous = null) + private function __construct(string $message, Throwable|null $previous = null) { parent::__construct($message, previous: $previous); } - public static function withOlderDb(?Throwable $prev = null): self + public static function withOlderDb(Throwable|null $prev = null): self { $e = new self( 'An error occurred while updating geolocation database, but an older DB is already present.', @@ -29,7 +29,7 @@ class GeolocationDbUpdateFailedException extends RuntimeException implements Exc return $e; } - public static function withoutOlderDb(?Throwable $prev = null): self + public static function withoutOlderDb(Throwable|null $prev = null): self { $e = new self( 'An error occurred while updating geolocation database, and an older version could not be found.', diff --git a/module/CLI/src/GeoLite/GeolocationDbUpdater.php b/module/CLI/src/GeoLite/GeolocationDbUpdater.php index 85ae1d3a..2abae05b 100644 --- a/module/CLI/src/GeoLite/GeolocationDbUpdater.php +++ b/module/CLI/src/GeoLite/GeolocationDbUpdater.php @@ -40,8 +40,10 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface /** * @throws GeolocationDbUpdateFailedException */ - public function checkDbUpdate(?callable $beforeDownload = null, ?callable $handleProgress = null): GeolocationResult - { + public function checkDbUpdate( + callable|null $beforeDownload = null, + callable|null $handleProgress = null, + ): GeolocationResult { if ($this->trackingOptions->disableTracking || $this->trackingOptions->disableIpTracking) { return GeolocationResult::CHECK_SKIPPED; } @@ -59,7 +61,7 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface /** * @throws GeolocationDbUpdateFailedException */ - private function downloadIfNeeded(?callable $beforeDownload, ?callable $handleProgress): GeolocationResult + private function downloadIfNeeded(callable|null $beforeDownload, callable|null $handleProgress): GeolocationResult { if (! $this->dbUpdater->databaseFileExists()) { return $this->downloadNewDb(false, $beforeDownload, $handleProgress); @@ -105,8 +107,8 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface */ private function downloadNewDb( bool $olderDbExists, - ?callable $beforeDownload, - ?callable $handleProgress, + callable|null $beforeDownload, + callable|null $handleProgress, ): GeolocationResult { if ($beforeDownload !== null) { $beforeDownload($olderDbExists); @@ -124,7 +126,7 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface } } - private function wrapHandleProgressCallback(?callable $handleProgress, bool $olderDbExists): ?callable + private function wrapHandleProgressCallback(callable|null $handleProgress, bool $olderDbExists): callable|null { if ($handleProgress === null) { return null; diff --git a/module/CLI/src/GeoLite/GeolocationDbUpdaterInterface.php b/module/CLI/src/GeoLite/GeolocationDbUpdaterInterface.php index a143abb8..ba0f0e70 100644 --- a/module/CLI/src/GeoLite/GeolocationDbUpdaterInterface.php +++ b/module/CLI/src/GeoLite/GeolocationDbUpdaterInterface.php @@ -12,7 +12,7 @@ interface GeolocationDbUpdaterInterface * @throws GeolocationDbUpdateFailedException */ public function checkDbUpdate( - ?callable $beforeDownload = null, - ?callable $handleProgress = null, + callable|null $beforeDownload = null, + callable|null $handleProgress = null, ): GeolocationResult; } diff --git a/module/CLI/src/Input/DateOption.php b/module/CLI/src/Input/DateOption.php index 6183a6c5..74acc162 100644 --- a/module/CLI/src/Input/DateOption.php +++ b/module/CLI/src/Input/DateOption.php @@ -21,7 +21,7 @@ readonly class DateOption $command->addOption($name, $shortcut, InputOption::VALUE_REQUIRED, $description); } - public function get(InputInterface $input, OutputInterface $output): ?Chronos + public function get(InputInterface $input, OutputInterface $output): Chronos|null { $value = $input->getOption($this->name); if (empty($value) || ! is_string($value)) { diff --git a/module/CLI/src/Input/EndDateOption.php b/module/CLI/src/Input/EndDateOption.php index 8e6df28a..f2073397 100644 --- a/module/CLI/src/Input/EndDateOption.php +++ b/module/CLI/src/Input/EndDateOption.php @@ -23,7 +23,7 @@ readonly final class EndDateOption )); } - public function get(InputInterface $input, OutputInterface $output): ?Chronos + public function get(InputInterface $input, OutputInterface $output): Chronos|null { return $this->dateOption->get($input, $output); } diff --git a/module/CLI/src/Input/ShortUrlDataOption.php b/module/CLI/src/Input/ShortUrlDataOption.php index 9774d8cb..29c41407 100644 --- a/module/CLI/src/Input/ShortUrlDataOption.php +++ b/module/CLI/src/Input/ShortUrlDataOption.php @@ -18,7 +18,7 @@ enum ShortUrlDataOption: string case CRAWLABLE = 'crawlable'; case NO_FORWARD_QUERY = 'no-forward-query'; - public function shortcut(): ?string + public function shortcut(): string|null { return match ($this) { self::TAGS => 't', diff --git a/module/CLI/src/Input/ShortUrlIdentifierInput.php b/module/CLI/src/Input/ShortUrlIdentifierInput.php index c07de779..def03f74 100644 --- a/module/CLI/src/Input/ShortUrlIdentifierInput.php +++ b/module/CLI/src/Input/ShortUrlIdentifierInput.php @@ -19,7 +19,7 @@ readonly final class ShortUrlIdentifierInput ->addOption('domain', 'd', InputOption::VALUE_REQUIRED, $domainDesc); } - public function shortCode(InputInterface $input): ?string + public function shortCode(InputInterface $input): string|null { return $input->getArgument('shortCode'); } diff --git a/module/CLI/src/Input/StartDateOption.php b/module/CLI/src/Input/StartDateOption.php index 6a7857d7..eaef301f 100644 --- a/module/CLI/src/Input/StartDateOption.php +++ b/module/CLI/src/Input/StartDateOption.php @@ -23,7 +23,7 @@ readonly final class StartDateOption )); } - public function get(InputInterface $input, OutputInterface $output): ?Chronos + public function get(InputInterface $input, OutputInterface $output): Chronos|null { return $this->dateOption->get($input, $output); } diff --git a/module/CLI/src/RedirectRule/RedirectRuleHandler.php b/module/CLI/src/RedirectRule/RedirectRuleHandler.php index cb1d3faf..924876fc 100644 --- a/module/CLI/src/RedirectRule/RedirectRuleHandler.php +++ b/module/CLI/src/RedirectRule/RedirectRuleHandler.php @@ -33,7 +33,7 @@ use const STR_PAD_LEFT; class RedirectRuleHandler implements RedirectRuleHandlerInterface { - public function manageRules(StyleInterface $io, ShortUrl $shortUrl, array $rules): ?array + public function manageRules(StyleInterface $io, ShortUrl $shortUrl, array $rules): array|null { $amountOfRules = count($rules); @@ -213,7 +213,7 @@ class RedirectRuleHandler implements RedirectRuleHandlerInterface private function askMandatory(string $message, StyleInterface $io): string { - return $io->ask($message, validator: function (?string $answer): string { + return $io->ask($message, validator: function (string|null $answer): string { if ($answer === null) { throw new InvalidArgumentException('The value is mandatory'); } @@ -223,6 +223,6 @@ class RedirectRuleHandler implements RedirectRuleHandlerInterface private function askOptional(string $message, StyleInterface $io): string { - return $io->ask($message, validator: fn (?string $answer) => $answer === null ? '' : trim($answer)); + return $io->ask($message, validator: fn (string|null $answer) => $answer === null ? '' : trim($answer)); } } diff --git a/module/CLI/src/RedirectRule/RedirectRuleHandlerInterface.php b/module/CLI/src/RedirectRule/RedirectRuleHandlerInterface.php index 16022768..e871bc81 100644 --- a/module/CLI/src/RedirectRule/RedirectRuleHandlerInterface.php +++ b/module/CLI/src/RedirectRule/RedirectRuleHandlerInterface.php @@ -16,5 +16,5 @@ interface RedirectRuleHandlerInterface * @param ShortUrlRedirectRule[] $rules * @return ShortUrlRedirectRule[]|null - A new list of rules to save, or null if no changes should be saved */ - public function manageRules(StyleInterface $io, ShortUrl $shortUrl, array $rules): ?array; + public function manageRules(StyleInterface $io, ShortUrl $shortUrl, array $rules): array|null; } diff --git a/module/CLI/src/Util/ProcessRunner.php b/module/CLI/src/Util/ProcessRunner.php index 5a568dbe..af9577ea 100644 --- a/module/CLI/src/Util/ProcessRunner.php +++ b/module/CLI/src/Util/ProcessRunner.php @@ -20,7 +20,7 @@ class ProcessRunner implements ProcessRunnerInterface { private Closure $createProcess; - public function __construct(private ProcessHelper $helper, ?callable $createProcess = null) + public function __construct(private ProcessHelper $helper, callable|null $createProcess = null) { $this->createProcess = $createProcess !== null ? $createProcess(...) diff --git a/module/CLI/src/Util/ShlinkTable.php b/module/CLI/src/Util/ShlinkTable.php index c421c613..10823734 100644 --- a/module/CLI/src/Util/ShlinkTable.php +++ b/module/CLI/src/Util/ShlinkTable.php @@ -34,8 +34,12 @@ final class ShlinkTable return new self($baseTable); } - public function render(array $headers, array $rows, ?string $footerTitle = null, ?string $headerTitle = null): void - { + public function render( + array $headers, + array $rows, + string|null $footerTitle = null, + string|null $headerTitle = null, + ): void { $style = Table::getStyleDefinition(self::DEFAULT_STYLE_NAME); $style->setFooterTitleFormat(self::TABLE_TITLE_STYLE) ->setHeaderTitleFormat(self::TABLE_TITLE_STYLE); diff --git a/module/CLI/test/Command/Api/InitialApiKeyCommandTest.php b/module/CLI/test/Command/Api/InitialApiKeyCommandTest.php index 482bd36f..e86cf0e5 100644 --- a/module/CLI/test/Command/Api/InitialApiKeyCommandTest.php +++ b/module/CLI/test/Command/Api/InitialApiKeyCommandTest.php @@ -27,8 +27,11 @@ class InitialApiKeyCommandTest extends TestCase } #[Test, DataProvider('provideParams')] - public function initialKeyIsCreatedWithProvidedValue(?ApiKey $result, bool $verbose, string $expectedOutput): void - { + public function initialKeyIsCreatedWithProvidedValue( + ApiKey|null $result, + bool $verbose, + string $expectedOutput, + ): void { $this->apiKeyService->expects($this->once())->method('createInitial')->with('the_key')->willReturn($result); $this->commandTester->execute( diff --git a/module/CLI/test/Command/Domain/DomainRedirectsCommandTest.php b/module/CLI/test/Command/Domain/DomainRedirectsCommandTest.php index 32240fc5..5215c2bc 100644 --- a/module/CLI/test/Command/Domain/DomainRedirectsCommandTest.php +++ b/module/CLI/test/Command/Domain/DomainRedirectsCommandTest.php @@ -31,7 +31,7 @@ class DomainRedirectsCommandTest extends TestCase } #[Test, DataProvider('provideDomains')] - public function onlyPlainQuestionsAreAskedForNewDomainsAndDomainsWithNoRedirects(?Domain $domain): void + public function onlyPlainQuestionsAreAskedForNewDomainsAndDomainsWithNoRedirects(Domain|null $domain): void { $domainAuthority = 'my-domain.com'; $this->domainService->expects($this->once())->method('findByAuthority')->with($domainAuthority)->willReturn( diff --git a/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php index 19c57481..bd694e7c 100644 --- a/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php @@ -104,7 +104,7 @@ class CreateShortUrlCommandTest extends TestCase } #[Test, DataProvider('provideDomains')] - public function properlyProcessesProvidedDomain(array $input, ?string $expectedDomain): void + public function properlyProcessesProvidedDomain(array $input, string|null $expectedDomain): void { $this->urlShortener->expects($this->once())->method('shorten')->with( $this->callback(function (ShortUrlCreation $meta) use ($expectedDomain) { @@ -128,8 +128,10 @@ class CreateShortUrlCommandTest extends TestCase } #[Test, DataProvider('provideFlags')] - public function urlValidationHasExpectedValueBasedOnProvidedFlags(array $options, ?bool $expectedCrawlable): void - { + public function urlValidationHasExpectedValueBasedOnProvidedFlags( + array $options, + bool|null $expectedCrawlable, + ): void { $shortUrl = ShortUrl::createFake(); $this->urlShortener->expects($this->once())->method('shorten')->with( $this->callback(function (ShortUrlCreation $meta) use ($expectedCrawlable) { diff --git a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php index c1a3ab23..ccdab885 100644 --- a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php @@ -198,12 +198,12 @@ class ListShortUrlsCommandTest extends TestCase #[Test, DataProvider('provideArgs')] public function serviceIsInvokedWithProvidedArgs( array $commandArgs, - ?int $page, - ?string $searchTerm, + int|null $page, + string|null $searchTerm, array $tags, string $tagsMode, - ?string $startDate = null, - ?string $endDate = null, + string|null $startDate = null, + string|null $endDate = null, ): void { $this->shortUrlService->expects($this->once())->method('listShortUrls')->with(ShortUrlsParams::fromRawData([ 'page' => $page, @@ -260,7 +260,7 @@ class ListShortUrlsCommandTest extends TestCase } #[Test, DataProvider('provideOrderBy')] - public function orderByIsProperlyComputed(array $commandArgs, ?string $expectedOrderBy): void + public function orderByIsProperlyComputed(array $commandArgs, string|null $expectedOrderBy): void { $this->shortUrlService->expects($this->once())->method('listShortUrls')->with(ShortUrlsParams::fromRawData([ 'orderBy' => $expectedOrderBy, diff --git a/module/CLI/test/Exception/GeolocationDbUpdateFailedExceptionTest.php b/module/CLI/test/Exception/GeolocationDbUpdateFailedExceptionTest.php index 3196dd04..519ddf02 100644 --- a/module/CLI/test/Exception/GeolocationDbUpdateFailedExceptionTest.php +++ b/module/CLI/test/Exception/GeolocationDbUpdateFailedExceptionTest.php @@ -15,7 +15,7 @@ use Throwable; class GeolocationDbUpdateFailedExceptionTest extends TestCase { #[Test, DataProvider('providePrev')] - public function withOlderDbBuildsException(?Throwable $prev): void + public function withOlderDbBuildsException(Throwable|null $prev): void { $e = GeolocationDbUpdateFailedException::withOlderDb($prev); @@ -29,7 +29,7 @@ class GeolocationDbUpdateFailedExceptionTest extends TestCase } #[Test, DataProvider('providePrev')] - public function withoutOlderDbBuildsException(?Throwable $prev): void + public function withoutOlderDbBuildsException(Throwable|null $prev): void { $e = GeolocationDbUpdateFailedException::withoutOlderDb($prev); diff --git a/module/CLI/test/GeoLite/GeolocationDbUpdaterTest.php b/module/CLI/test/GeoLite/GeolocationDbUpdaterTest.php index 3b0f452e..c1cd48f5 100644 --- a/module/CLI/test/GeoLite/GeolocationDbUpdaterTest.php +++ b/module/CLI/test/GeoLite/GeolocationDbUpdaterTest.php @@ -180,7 +180,7 @@ class GeolocationDbUpdaterTest extends TestCase yield 'both' => [new TrackingOptions(disableTracking: true, disableIpTracking: true)]; } - private function geolocationDbUpdater(?TrackingOptions $options = null): GeolocationDbUpdater + private function geolocationDbUpdater(TrackingOptions|null $options = null): GeolocationDbUpdater { $locker = $this->createMock(Lock\LockFactory::class); $locker->method('createLock')->with($this->isType('string'))->willReturn($this->lock); diff --git a/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php b/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php index edd1eae3..18713e00 100644 --- a/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php +++ b/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php @@ -56,7 +56,7 @@ class RedirectRuleHandlerTest extends TestCase #[Test, DataProvider('provideExitActions')] public function commentIsDisplayedWhenRulesListIsEmpty( RedirectRuleHandlerAction $action, - ?array $expectedResult, + array|null $expectedResult, ): void { $this->io->expects($this->once())->method('choice')->willReturn($action->value); $this->io->expects($this->once())->method('newLine'); diff --git a/module/Core/functions/functions.php b/module/Core/functions/functions.php index f7bd0cdf..00b220e9 100644 --- a/module/Core/functions/functions.php +++ b/module/Core/functions/functions.php @@ -50,7 +50,7 @@ function generateRandomShortCode(int $length, ShortUrlMode $mode = ShortUrlMode: return $nanoIdClient->formattedId($alphabet, $length); } -function parseDateFromQuery(array $query, string $dateName): ?Chronos +function parseDateFromQuery(array $query, string $dateName): Chronos|null { return normalizeOptionalDate(empty($query[$dateName] ?? null) ? null : Chronos::parse($query[$dateName])); } @@ -63,7 +63,7 @@ function parseDateRangeFromQuery(array $query, string $startDateName, string $en return buildDateRange($startDate, $endDate); } -function dateRangeToHumanFriendly(?DateRange $dateRange): string +function dateRangeToHumanFriendly(DateRange|null $dateRange): string { $startDate = $dateRange?->startDate; $endDate = $dateRange?->endDate; @@ -83,7 +83,7 @@ function dateRangeToHumanFriendly(?DateRange $dateRange): string /** * @return ($date is null ? null : Chronos) */ -function normalizeOptionalDate(string|DateTimeInterface|Chronos|null $date): ?Chronos +function normalizeOptionalDate(string|DateTimeInterface|Chronos|null $date): Chronos|null { $parsedDate = match (true) { $date === null || $date instanceof Chronos => $date, @@ -148,7 +148,7 @@ function splitLocale(string $locale): array /** * @param InputFilter $inputFilter */ -function getOptionalIntFromInputFilter(InputFilter $inputFilter, string $fieldName): ?int +function getOptionalIntFromInputFilter(InputFilter $inputFilter, string $fieldName): int|null { $value = $inputFilter->getValue($fieldName); return $value !== null ? (int) $value : null; @@ -157,7 +157,7 @@ function getOptionalIntFromInputFilter(InputFilter $inputFilter, string $fieldNa /** * @param InputFilter $inputFilter */ -function getOptionalBoolFromInputFilter(InputFilter $inputFilter, string $fieldName): ?bool +function getOptionalBoolFromInputFilter(InputFilter $inputFilter, string $fieldName): bool|null { $value = $inputFilter->getValue($fieldName); return $value !== null ? (bool) $value : null; @@ -276,7 +276,7 @@ function enumToString(string $enum): string * Split provided string by comma and return a list of the results. * An empty array is returned if provided value is empty */ -function splitByComma(?string $value): array +function splitByComma(string|null $value): array { if ($value === null || trim($value) === '') { return []; @@ -285,7 +285,7 @@ function splitByComma(?string $value): array return array_map(trim(...), explode(',', $value)); } -function ipAddressFromRequest(ServerRequestInterface $request): ?string +function ipAddressFromRequest(ServerRequestInterface $request): string|null { return $request->getAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR); } diff --git a/module/Core/src/Action/Model/QrCodeParams.php b/module/Core/src/Action/Model/QrCodeParams.php index 46d90056..3be9097e 100644 --- a/module/Core/src/Action/Model/QrCodeParams.php +++ b/module/Core/src/Action/Model/QrCodeParams.php @@ -123,7 +123,7 @@ final class QrCodeParams return self::parseHexColor($bgColor, DEFAULT_QR_CODE_BG_COLOR); } - private static function parseHexColor(string $hexColor, ?string $fallback): Color + private static function parseHexColor(string $hexColor, string|null $fallback): Color { $hexColor = ltrim($hexColor, '#'); if (! ctype_xdigit($hexColor) && $fallback !== null) { diff --git a/module/Core/src/Config/EmptyNotFoundRedirectConfig.php b/module/Core/src/Config/EmptyNotFoundRedirectConfig.php index 6ccb3848..5ec23bc1 100644 --- a/module/Core/src/Config/EmptyNotFoundRedirectConfig.php +++ b/module/Core/src/Config/EmptyNotFoundRedirectConfig.php @@ -6,7 +6,7 @@ namespace Shlinkio\Shlink\Core\Config; final class EmptyNotFoundRedirectConfig implements NotFoundRedirectConfigInterface { - public function invalidShortUrlRedirect(): ?string + public function invalidShortUrlRedirect(): string|null { return null; } @@ -16,7 +16,7 @@ final class EmptyNotFoundRedirectConfig implements NotFoundRedirectConfigInterfa return false; } - public function regular404Redirect(): ?string + public function regular404Redirect(): string|null { return null; } @@ -26,7 +26,7 @@ final class EmptyNotFoundRedirectConfig implements NotFoundRedirectConfigInterfa return false; } - public function baseUrlRedirect(): ?string + public function baseUrlRedirect(): string|null { return null; } diff --git a/module/Core/src/Config/NotFoundRedirectConfigInterface.php b/module/Core/src/Config/NotFoundRedirectConfigInterface.php index bbdfa9c5..46c2c734 100644 --- a/module/Core/src/Config/NotFoundRedirectConfigInterface.php +++ b/module/Core/src/Config/NotFoundRedirectConfigInterface.php @@ -6,15 +6,15 @@ namespace Shlinkio\Shlink\Core\Config; interface NotFoundRedirectConfigInterface { - public function invalidShortUrlRedirect(): ?string; + public function invalidShortUrlRedirect(): string|null; public function hasInvalidShortUrlRedirect(): bool; - public function regular404Redirect(): ?string; + public function regular404Redirect(): string|null; public function hasRegular404Redirect(): bool; - public function baseUrlRedirect(): ?string; + public function baseUrlRedirect(): string|null; public function hasBaseUrlRedirect(): bool; } diff --git a/module/Core/src/Config/NotFoundRedirectResolver.php b/module/Core/src/Config/NotFoundRedirectResolver.php index cfb09c8e..657336c1 100644 --- a/module/Core/src/Config/NotFoundRedirectResolver.php +++ b/module/Core/src/Config/NotFoundRedirectResolver.php @@ -30,7 +30,7 @@ class NotFoundRedirectResolver implements NotFoundRedirectResolverInterface NotFoundType $notFoundType, NotFoundRedirectConfigInterface $config, UriInterface $currentUri, - ): ?ResponseInterface { + ): ResponseInterface|null { $urlToRedirectTo = match (true) { $notFoundType->isBaseUrl() && $config->hasBaseUrlRedirect() => $config->baseUrlRedirect(), $notFoundType->isRegularNotFound() && $config->hasRegular404Redirect() => $config->regular404Redirect(), diff --git a/module/Core/src/Config/NotFoundRedirectResolverInterface.php b/module/Core/src/Config/NotFoundRedirectResolverInterface.php index 6cbdf702..5f214ca9 100644 --- a/module/Core/src/Config/NotFoundRedirectResolverInterface.php +++ b/module/Core/src/Config/NotFoundRedirectResolverInterface.php @@ -14,5 +14,5 @@ interface NotFoundRedirectResolverInterface NotFoundType $notFoundType, NotFoundRedirectConfigInterface $config, UriInterface $currentUri, - ): ?ResponseInterface; + ): ResponseInterface|null; } diff --git a/module/Core/src/Config/NotFoundRedirects.php b/module/Core/src/Config/NotFoundRedirects.php index 48437924..2753d44f 100644 --- a/module/Core/src/Config/NotFoundRedirects.php +++ b/module/Core/src/Config/NotFoundRedirects.php @@ -9,16 +9,16 @@ use JsonSerializable; final class NotFoundRedirects implements JsonSerializable { private function __construct( - public readonly ?string $baseUrlRedirect, - public readonly ?string $regular404Redirect, - public readonly ?string $invalidShortUrlRedirect, + public readonly string|null $baseUrlRedirect, + public readonly string|null $regular404Redirect, + public readonly string|null $invalidShortUrlRedirect, ) { } public static function withRedirects( - ?string $baseUrlRedirect, - ?string $regular404Redirect = null, - ?string $invalidShortUrlRedirect = null, + string|null $baseUrlRedirect, + string|null $regular404Redirect = null, + string|null $invalidShortUrlRedirect = null, ): self { return new self($baseUrlRedirect, $regular404Redirect, $invalidShortUrlRedirect); } diff --git a/module/Core/src/Config/Options/NotFoundRedirectOptions.php b/module/Core/src/Config/Options/NotFoundRedirectOptions.php index e6ef6a24..7c04d077 100644 --- a/module/Core/src/Config/Options/NotFoundRedirectOptions.php +++ b/module/Core/src/Config/Options/NotFoundRedirectOptions.php @@ -10,9 +10,9 @@ use Shlinkio\Shlink\Core\Config\NotFoundRedirectConfigInterface; final readonly class NotFoundRedirectOptions implements NotFoundRedirectConfigInterface { public function __construct( - public ?string $invalidShortUrl = null, - public ?string $regular404 = null, - public ?string $baseUrl = null, + public string|null $invalidShortUrl = null, + public string|null $regular404 = null, + public string|null $baseUrl = null, ) { } @@ -25,7 +25,7 @@ final readonly class NotFoundRedirectOptions implements NotFoundRedirectConfigIn ); } - public function invalidShortUrlRedirect(): ?string + public function invalidShortUrlRedirect(): string|null { return $this->invalidShortUrl; } @@ -35,7 +35,7 @@ final readonly class NotFoundRedirectOptions implements NotFoundRedirectConfigIn return $this->invalidShortUrl !== null; } - public function regular404Redirect(): ?string + public function regular404Redirect(): string|null { return $this->regular404; } @@ -45,7 +45,7 @@ final readonly class NotFoundRedirectOptions implements NotFoundRedirectConfigIn return $this->regular404 !== null; } - public function baseUrlRedirect(): ?string + public function baseUrlRedirect(): string|null { return $this->baseUrl; } diff --git a/module/Core/src/Config/Options/QrCodeOptions.php b/module/Core/src/Config/Options/QrCodeOptions.php index 4d85e6cc..ac864851 100644 --- a/module/Core/src/Config/Options/QrCodeOptions.php +++ b/module/Core/src/Config/Options/QrCodeOptions.php @@ -26,7 +26,7 @@ final readonly class QrCodeOptions public bool $enabledForDisabledShortUrls = DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS, public string $color = DEFAULT_QR_CODE_COLOR, public string $bgColor = DEFAULT_QR_CODE_BG_COLOR, - public ?string $logoUrl = null, + public string|null $logoUrl = null, ) { } diff --git a/module/Core/src/Config/Options/TrackingOptions.php b/module/Core/src/Config/Options/TrackingOptions.php index eddfba34..d238bb42 100644 --- a/module/Core/src/Config/Options/TrackingOptions.php +++ b/module/Core/src/Config/Options/TrackingOptions.php @@ -22,7 +22,7 @@ final readonly class TrackingOptions public bool $trackOrphanVisits = true, // A query param that, if provided, will disable tracking of one particular visit. Always takes precedence over // other options - public ?string $disableTrackParam = null, + public string|null $disableTrackParam = null, // If true, visits will not be tracked at all public bool $disableTracking = false, // If true, visits will be tracked, but neither the IP address, nor the location will be resolved diff --git a/module/Core/src/Domain/DomainService.php b/module/Core/src/Domain/DomainService.php index e514af55..18d66328 100644 --- a/module/Core/src/Domain/DomainService.php +++ b/module/Core/src/Domain/DomainService.php @@ -26,7 +26,7 @@ readonly class DomainService implements DomainServiceInterface /** * @return DomainItem[] */ - public function listDomains(?ApiKey $apiKey = null): array + public function listDomains(ApiKey|null $apiKey = null): array { [$default, $domains] = $this->defaultDomainAndRest($apiKey); $mappedDomains = array_map(fn (Domain $domain) => DomainItem::forNonDefaultDomain($domain), $domains); @@ -47,7 +47,7 @@ readonly class DomainService implements DomainServiceInterface /** * @return array{Domain|null, Domain[]} */ - private function defaultDomainAndRest(?ApiKey $apiKey): array + private function defaultDomainAndRest(ApiKey|null $apiKey): array { /** @var DomainRepositoryInterface $repo */ $repo = $this->em->getRepository(Domain::class); @@ -80,7 +80,7 @@ readonly class DomainService implements DomainServiceInterface return $domain; } - public function findByAuthority(string $authority, ?ApiKey $apiKey = null): ?Domain + public function findByAuthority(string $authority, ApiKey|null $apiKey = null): Domain|null { return $this->em->getRepository(Domain::class)->findOneByAuthority($authority, $apiKey); } @@ -88,7 +88,7 @@ readonly class DomainService implements DomainServiceInterface /** * @throws DomainNotFoundException */ - public function getOrCreate(string $authority, ?ApiKey $apiKey = null): Domain + public function getOrCreate(string $authority, ApiKey|null $apiKey = null): Domain { $domain = $this->getPersistedDomain($authority, $apiKey); $this->em->flush(); @@ -102,7 +102,7 @@ readonly class DomainService implements DomainServiceInterface public function configureNotFoundRedirects( string $authority, NotFoundRedirects $notFoundRedirects, - ?ApiKey $apiKey = null, + ApiKey|null $apiKey = null, ): Domain { $domain = $this->getPersistedDomain($authority, $apiKey); $domain->configureNotFoundRedirects($notFoundRedirects); @@ -115,7 +115,7 @@ readonly class DomainService implements DomainServiceInterface /** * @throws DomainNotFoundException */ - private function getPersistedDomain(string $authority, ?ApiKey $apiKey): Domain + private function getPersistedDomain(string $authority, ApiKey|null $apiKey): Domain { $domain = $this->findByAuthority($authority, $apiKey); if ($domain === null && $apiKey?->hasRole(Role::DOMAIN_SPECIFIC)) { diff --git a/module/Core/src/Domain/DomainServiceInterface.php b/module/Core/src/Domain/DomainServiceInterface.php index 103abbb2..b7f8b3ee 100644 --- a/module/Core/src/Domain/DomainServiceInterface.php +++ b/module/Core/src/Domain/DomainServiceInterface.php @@ -15,7 +15,7 @@ interface DomainServiceInterface /** * @return DomainItem[] */ - public function listDomains(?ApiKey $apiKey = null): array; + public function listDomains(ApiKey|null $apiKey = null): array; /** * @throws DomainNotFoundException @@ -25,9 +25,9 @@ interface DomainServiceInterface /** * @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 getOrCreate(string $authority, ApiKey|null $apiKey = null): Domain; - public function findByAuthority(string $authority, ?ApiKey $apiKey = null): ?Domain; + public function findByAuthority(string $authority, ApiKey|null $apiKey = null): Domain|null; /** * @throws DomainNotFoundException If the API key is restricted to one domain and a different one is provided @@ -35,6 +35,6 @@ interface DomainServiceInterface public function configureNotFoundRedirects( string $authority, NotFoundRedirects $notFoundRedirects, - ?ApiKey $apiKey = null, + ApiKey|null $apiKey = null, ): Domain; } diff --git a/module/Core/src/Domain/Entity/Domain.php b/module/Core/src/Domain/Entity/Domain.php index ba3446a7..628335cd 100644 --- a/module/Core/src/Domain/Entity/Domain.php +++ b/module/Core/src/Domain/Entity/Domain.php @@ -15,9 +15,9 @@ class Domain extends AbstractEntity implements JsonSerializable, NotFoundRedirec private function __construct( public readonly string $authority, - private ?string $baseUrlRedirect = null, - private ?string $regular404Redirect = null, - private ?string $invalidShortUrlRedirect = null, + private string|null $baseUrlRedirect = null, + private string|null $regular404Redirect = null, + private string|null $invalidShortUrlRedirect = null, ) { } @@ -31,7 +31,7 @@ class Domain extends AbstractEntity implements JsonSerializable, NotFoundRedirec return $this->authority; } - public function invalidShortUrlRedirect(): ?string + public function invalidShortUrlRedirect(): string|null { return $this->invalidShortUrlRedirect; } @@ -41,7 +41,7 @@ class Domain extends AbstractEntity implements JsonSerializable, NotFoundRedirec return $this->invalidShortUrlRedirect !== null; } - public function regular404Redirect(): ?string + public function regular404Redirect(): string|null { return $this->regular404Redirect; } @@ -51,7 +51,7 @@ class Domain extends AbstractEntity implements JsonSerializable, NotFoundRedirec return $this->regular404Redirect !== null; } - public function baseUrlRedirect(): ?string + public function baseUrlRedirect(): string|null { return $this->baseUrlRedirect; } diff --git a/module/Core/src/Domain/Repository/DomainRepository.php b/module/Core/src/Domain/Repository/DomainRepository.php index fedf4f54..0a1fe40a 100644 --- a/module/Core/src/Domain/Repository/DomainRepository.php +++ b/module/Core/src/Domain/Repository/DomainRepository.php @@ -20,7 +20,7 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe /** * @return Domain[] */ - public function findDomains(?ApiKey $apiKey = null): array + public function findDomains(ApiKey|null $apiKey = null): array { $qb = $this->createQueryBuilder('d'); $qb->leftJoin(ShortUrl::class, 's', Join::WITH, 's.domain = d') @@ -39,7 +39,7 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe return $qb->getQuery()->getResult(); } - public function findOneByAuthority(string $authority, ?ApiKey $apiKey = null): ?Domain + public function findOneByAuthority(string $authority, ApiKey|null $apiKey = null): Domain|null { $qb = $this->createDomainQueryBuilder($authority, $apiKey); $qb->select('d'); @@ -47,7 +47,7 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe return $qb->getQuery()->getOneOrNullResult(); } - public function domainExists(string $authority, ?ApiKey $apiKey = null): bool + public function domainExists(string $authority, ApiKey|null $apiKey = null): bool { $qb = $this->createDomainQueryBuilder($authority, $apiKey); $qb->select('COUNT(d.id)'); @@ -55,7 +55,7 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe return ((int) $qb->getQuery()->getSingleScalarResult()) > 0; } - private function createDomainQueryBuilder(string $authority, ?ApiKey $apiKey): QueryBuilder + private function createDomainQueryBuilder(string $authority, ApiKey|null $apiKey): QueryBuilder { $qb = $this->getEntityManager()->createQueryBuilder(); $qb->from(Domain::class, 'd') @@ -72,7 +72,7 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe return $qb; } - private function determineExtraSpecs(?ApiKey $apiKey): iterable + private function determineExtraSpecs(ApiKey|null $apiKey): iterable { // 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. diff --git a/module/Core/src/Domain/Repository/DomainRepositoryInterface.php b/module/Core/src/Domain/Repository/DomainRepositoryInterface.php index d215e475..cc14bb10 100644 --- a/module/Core/src/Domain/Repository/DomainRepositoryInterface.php +++ b/module/Core/src/Domain/Repository/DomainRepositoryInterface.php @@ -15,9 +15,9 @@ interface DomainRepositoryInterface extends ObjectRepository, EntitySpecificatio /** * @return Domain[] */ - public function findDomains(?ApiKey $apiKey = null): array; + public function findDomains(ApiKey|null $apiKey = null): array; - public function findOneByAuthority(string $authority, ?ApiKey $apiKey = null): ?Domain; + public function findOneByAuthority(string $authority, ApiKey|null $apiKey = null): Domain|null; - public function domainExists(string $authority, ?ApiKey $apiKey = null): bool; + public function domainExists(string $authority, ApiKey|null $apiKey = null): bool; } diff --git a/module/Core/src/Domain/Spec/IsDomain.php b/module/Core/src/Domain/Spec/IsDomain.php index cf7463cc..2c78a85e 100644 --- a/module/Core/src/Domain/Spec/IsDomain.php +++ b/module/Core/src/Domain/Spec/IsDomain.php @@ -10,7 +10,7 @@ use Happyr\DoctrineSpecification\Specification\BaseSpecification; class IsDomain extends BaseSpecification { - public function __construct(private string $domainId, ?string $context = null) + public function __construct(private string $domainId, string|null $context = null) { parent::__construct($context); } diff --git a/module/Core/src/ErrorHandler/Model/NotFoundType.php b/module/Core/src/ErrorHandler/Model/NotFoundType.php index 99f7fbe6..de0c5460 100644 --- a/module/Core/src/ErrorHandler/Model/NotFoundType.php +++ b/module/Core/src/ErrorHandler/Model/NotFoundType.php @@ -13,7 +13,7 @@ use function rtrim; class NotFoundType { - private function __construct(private readonly ?VisitType $type) + private function __construct(private readonly VisitType|null $type) { } diff --git a/module/Core/src/ErrorHandler/NotFoundRedirectHandler.php b/module/Core/src/ErrorHandler/NotFoundRedirectHandler.php index 4e7360d5..f84123c1 100644 --- a/module/Core/src/ErrorHandler/NotFoundRedirectHandler.php +++ b/module/Core/src/ErrorHandler/NotFoundRedirectHandler.php @@ -40,7 +40,7 @@ readonly class NotFoundRedirectHandler implements MiddlewareInterface private function resolveDomainSpecificRedirect( UriInterface $currentUri, NotFoundType $notFoundType, - ): ?ResponseInterface { + ): ResponseInterface|null { $domain = $this->domainService->findByAuthority($currentUri->getAuthority()); if ($domain === null) { return null; diff --git a/module/Core/src/ErrorHandler/NotFoundTemplateHandler.php b/module/Core/src/ErrorHandler/NotFoundTemplateHandler.php index cd0f60be..9b59f886 100644 --- a/module/Core/src/ErrorHandler/NotFoundTemplateHandler.php +++ b/module/Core/src/ErrorHandler/NotFoundTemplateHandler.php @@ -23,7 +23,7 @@ class NotFoundTemplateHandler implements RequestHandlerInterface private Closure $readFile; - public function __construct(?callable $readFile = null) + public function __construct(callable|null $readFile = null) { $this->readFile = $readFile ? Closure::fromCallable($readFile) : fn (string $file) => file_get_contents($file); } diff --git a/module/Core/src/EventDispatcher/Event/AbstractVisitEvent.php b/module/Core/src/EventDispatcher/Event/AbstractVisitEvent.php index 87f7dba2..c1fa440a 100644 --- a/module/Core/src/EventDispatcher/Event/AbstractVisitEvent.php +++ b/module/Core/src/EventDispatcher/Event/AbstractVisitEvent.php @@ -11,7 +11,7 @@ abstract class AbstractVisitEvent implements JsonSerializable, JsonUnserializabl { final public function __construct( public readonly string $visitId, - public readonly ?string $originalIpAddress = null, + public readonly string|null $originalIpAddress = null, ) { } diff --git a/module/Core/src/EventDispatcher/LocateVisit.php b/module/Core/src/EventDispatcher/LocateVisit.php index 6f7fb7e8..d1d0d90a 100644 --- a/module/Core/src/EventDispatcher/LocateVisit.php +++ b/module/Core/src/EventDispatcher/LocateVisit.php @@ -45,7 +45,7 @@ readonly class LocateVisit $this->eventDispatcher->dispatch(new VisitLocated($visitId, $shortUrlVisited->originalIpAddress)); } - private function locateVisit(string $visitId, ?string $originalIpAddress, Visit $visit): void + private function locateVisit(string $visitId, string|null $originalIpAddress, Visit $visit): void { if (! $this->dbUpdater->databaseFileExists()) { $this->logger->warning('Tried to locate visit with id "{visitId}", but a GeoLite2 db was not found.', [ diff --git a/module/Core/src/EventDispatcher/PublishingUpdatesGenerator.php b/module/Core/src/EventDispatcher/PublishingUpdatesGenerator.php index b762af7e..e9437cc3 100644 --- a/module/Core/src/EventDispatcher/PublishingUpdatesGenerator.php +++ b/module/Core/src/EventDispatcher/PublishingUpdatesGenerator.php @@ -48,7 +48,7 @@ final readonly class PublishingUpdatesGenerator implements PublishingUpdatesGene ]); } - private function transformShortUrl(?ShortUrl $shortUrl): array + private function transformShortUrl(ShortUrl|null $shortUrl): array { return $shortUrl === null ? [] : $this->shortUrlTransformer->transform($shortUrl); } diff --git a/module/Core/src/EventDispatcher/Topic.php b/module/Core/src/EventDispatcher/Topic.php index 0cba5a09..8c7a7d45 100644 --- a/module/Core/src/EventDispatcher/Topic.php +++ b/module/Core/src/EventDispatcher/Topic.php @@ -12,7 +12,7 @@ enum Topic: string case NEW_ORPHAN_VISIT = 'https://shlink.io/new-orphan-visit'; case NEW_SHORT_URL = 'https://shlink.io/new-short-url'; - public static function newShortUrlVisit(?string $shortCode): string + public static function newShortUrlVisit(string|null $shortCode): string { return sprintf('%s/%s', self::NEW_VISIT->value, $shortCode ?? ''); } diff --git a/module/Core/src/Exception/IpCannotBeLocatedException.php b/module/Core/src/Exception/IpCannotBeLocatedException.php index 2ebc3e62..d22d341f 100644 --- a/module/Core/src/Exception/IpCannotBeLocatedException.php +++ b/module/Core/src/Exception/IpCannotBeLocatedException.php @@ -13,7 +13,7 @@ class IpCannotBeLocatedException extends RuntimeException string $message, public readonly UnlocatableIpType $type, int $code = 0, - ?Throwable $previous = null, + Throwable|null $previous = null, ) { parent::__construct($message, $code, $previous); } diff --git a/module/Core/src/Exception/NonUniqueSlugException.php b/module/Core/src/Exception/NonUniqueSlugException.php index 5336786c..8f9508a2 100644 --- a/module/Core/src/Exception/NonUniqueSlugException.php +++ b/module/Core/src/Exception/NonUniqueSlugException.php @@ -19,7 +19,7 @@ class NonUniqueSlugException extends InvalidArgumentException implements Problem private const TITLE = 'Invalid custom slug'; public const ERROR_CODE = 'non-unique-slug'; - public static function fromSlug(string $slug, ?string $domain = null): self + public static function fromSlug(string $slug, string|null $domain = null): self { $suffix = $domain === null ? '' : sprintf(' for domain "%s"', $domain); $e = new self(sprintf('Provided slug "%s" is already in use%s.', $slug, $suffix)); diff --git a/module/Core/src/Exception/ValidationException.php b/module/Core/src/Exception/ValidationException.php index 95da6d5e..f81c1d37 100644 --- a/module/Core/src/Exception/ValidationException.php +++ b/module/Core/src/Exception/ValidationException.php @@ -29,12 +29,12 @@ class ValidationException extends InvalidArgumentException implements ProblemDet /** * @param InputFilterInterface $inputFilter */ - public static function fromInputFilter(InputFilterInterface $inputFilter, ?Throwable $prev = null): self + public static function fromInputFilter(InputFilterInterface $inputFilter, Throwable|null $prev = null): self { return static::fromArray($inputFilter->getMessages(), $prev); } - public static function fromArray(array $invalidData, ?Throwable $prev = null): self + public static function fromArray(array $invalidData, Throwable|null $prev = null): self { $status = StatusCodeInterface::STATUS_BAD_REQUEST; $e = new self('Provided data is not valid', $status, $prev); diff --git a/module/Core/src/Matomo/MatomoOptions.php b/module/Core/src/Matomo/MatomoOptions.php index af0ace92..5f47280e 100644 --- a/module/Core/src/Matomo/MatomoOptions.php +++ b/module/Core/src/Matomo/MatomoOptions.php @@ -13,9 +13,9 @@ final readonly class MatomoOptions */ public function __construct( public bool $enabled = false, - public ?string $baseUrl = null, + public string|null $baseUrl = null, private string|int|null $siteId = null, - public ?string $apiToken = null, + public string|null $apiToken = null, ) { } @@ -29,7 +29,7 @@ final readonly class MatomoOptions ); } - public function siteId(): ?int + public function siteId(): int|null { if ($this->siteId === null) { return null; diff --git a/module/Core/src/Matomo/MatomoVisitSender.php b/module/Core/src/Matomo/MatomoVisitSender.php index d2a4484a..9fc0176a 100644 --- a/module/Core/src/Matomo/MatomoVisitSender.php +++ b/module/Core/src/Matomo/MatomoVisitSender.php @@ -45,7 +45,7 @@ readonly class MatomoVisitSender implements MatomoVisitSenderInterface return new SendVisitsResult($successfulVisits, $failedVisits); } - public function sendVisit(Visit $visit, ?string $originalIpAddress = null): void + public function sendVisit(Visit $visit, string|null $originalIpAddress = null): void { $tracker = $this->trackerBuilder->buildMatomoTracker(); diff --git a/module/Core/src/Matomo/MatomoVisitSenderInterface.php b/module/Core/src/Matomo/MatomoVisitSenderInterface.php index e1b1c3cb..6390104c 100644 --- a/module/Core/src/Matomo/MatomoVisitSenderInterface.php +++ b/module/Core/src/Matomo/MatomoVisitSenderInterface.php @@ -18,5 +18,5 @@ interface MatomoVisitSenderInterface VisitSendingProgressTrackerInterface|null $progressTracker = null, ): SendVisitsResult; - public function sendVisit(Visit $visit, ?string $originalIpAddress = null): void; + public function sendVisit(Visit $visit, string|null $originalIpAddress = null): void; } diff --git a/module/Core/src/Model/AbstractInfinitePaginableListParams.php b/module/Core/src/Model/AbstractInfinitePaginableListParams.php index d4b2aaab..70db853e 100644 --- a/module/Core/src/Model/AbstractInfinitePaginableListParams.php +++ b/module/Core/src/Model/AbstractInfinitePaginableListParams.php @@ -13,18 +13,18 @@ abstract class AbstractInfinitePaginableListParams public readonly int $page; public readonly int $itemsPerPage; - protected function __construct(?int $page, ?int $itemsPerPage) + protected function __construct(int|null $page, int|null $itemsPerPage) { $this->page = $this->determinePage($page); $this->itemsPerPage = $this->determineItemsPerPage($itemsPerPage); } - private function determinePage(?int $page): int + private function determinePage(int|null $page): int { return $page === null || $page <= 0 ? self::FIRST_PAGE : $page; } - private function determineItemsPerPage(?int $itemsPerPage): int + private function determineItemsPerPage(int|null $itemsPerPage): int { return $itemsPerPage === null || $itemsPerPage < 0 ? Paginator::ALL_ITEMS : $itemsPerPage; } diff --git a/module/Core/src/Model/DeviceType.php b/module/Core/src/Model/DeviceType.php index 3cd3e132..a4a15cdc 100644 --- a/module/Core/src/Model/DeviceType.php +++ b/module/Core/src/Model/DeviceType.php @@ -10,7 +10,7 @@ enum DeviceType: string case IOS = 'ios'; case DESKTOP = 'desktop'; - public static function matchFromUserAgent(string $userAgent): ?self + public static function matchFromUserAgent(string $userAgent): self|null { $detect = new MobileDetect(); $detect->setUserAgent($userAgent); diff --git a/module/Core/src/Model/Ordering.php b/module/Core/src/Model/Ordering.php index e1b91510..0e0edab7 100644 --- a/module/Core/src/Model/Ordering.php +++ b/module/Core/src/Model/Ordering.php @@ -10,7 +10,7 @@ final readonly class Ordering private const ASC_DIR = 'ASC'; private const DEFAULT_DIR = self::ASC_DIR; - public function __construct(public ?string $field = null, public string $direction = self::DEFAULT_DIR) + public function __construct(public string|null $field = null, public string $direction = self::DEFAULT_DIR) { } diff --git a/module/Core/src/Paginator/Adapter/AbstractCacheableCountPaginatorAdapter.php b/module/Core/src/Paginator/Adapter/AbstractCacheableCountPaginatorAdapter.php index 890c8845..e2a3b414 100644 --- a/module/Core/src/Paginator/Adapter/AbstractCacheableCountPaginatorAdapter.php +++ b/module/Core/src/Paginator/Adapter/AbstractCacheableCountPaginatorAdapter.php @@ -12,7 +12,7 @@ use Pagerfanta\Adapter\AdapterInterface; */ abstract class AbstractCacheableCountPaginatorAdapter implements AdapterInterface { - private ?int $count = null; + private int|null $count = null; final public function getNbResults(): int { diff --git a/module/Core/src/RedirectRule/Entity/RedirectCondition.php b/module/Core/src/RedirectRule/Entity/RedirectCondition.php index 59c2798b..99f5fb9c 100644 --- a/module/Core/src/RedirectRule/Entity/RedirectCondition.php +++ b/module/Core/src/RedirectRule/Entity/RedirectCondition.php @@ -24,7 +24,7 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable private function __construct( private readonly RedirectConditionType $type, private readonly string $matchValue, - private readonly ?string $matchKey = null, + private readonly string|null $matchKey = null, ) { } diff --git a/module/Core/src/ShortUrl/DeleteShortUrlService.php b/module/Core/src/ShortUrl/DeleteShortUrlService.php index aeb08c47..b6ca5e8c 100644 --- a/module/Core/src/ShortUrl/DeleteShortUrlService.php +++ b/module/Core/src/ShortUrl/DeleteShortUrlService.php @@ -30,7 +30,7 @@ readonly class DeleteShortUrlService implements DeleteShortUrlServiceInterface public function deleteByShortCode( ShortUrlIdentifier $identifier, bool $ignoreThreshold = false, - ?ApiKey $apiKey = null, + ApiKey|null $apiKey = null, ): void { $shortUrl = $this->urlResolver->resolveShortUrl($identifier, $apiKey); if (! $ignoreThreshold && $this->isThresholdReached($shortUrl)) { diff --git a/module/Core/src/ShortUrl/DeleteShortUrlServiceInterface.php b/module/Core/src/ShortUrl/DeleteShortUrlServiceInterface.php index 32eaffa1..e511c9e5 100644 --- a/module/Core/src/ShortUrl/DeleteShortUrlServiceInterface.php +++ b/module/Core/src/ShortUrl/DeleteShortUrlServiceInterface.php @@ -18,7 +18,7 @@ interface DeleteShortUrlServiceInterface public function deleteByShortCode( ShortUrlIdentifier $identifier, bool $ignoreThreshold = false, - ?ApiKey $apiKey = null, + ApiKey|null $apiKey = null, ): void; /** diff --git a/module/Core/src/ShortUrl/Entity/ShortUrl.php b/module/Core/src/ShortUrl/Entity/ShortUrl.php index ac50064c..39bc5ed6 100644 --- a/module/Core/src/ShortUrl/Entity/ShortUrl.php +++ b/module/Core/src/ShortUrl/Entity/ShortUrl.php @@ -49,19 +49,19 @@ class ShortUrl extends AbstractEntity private Collection $tags = new ArrayCollection(), private Collection & Selectable $visits = new ArrayCollection(), private Collection & Selectable $visitsCounts = new ArrayCollection(), - private ?Chronos $validSince = null, - private ?Chronos $validUntil = null, - private ?int $maxVisits = null, - private ?Domain $domain = null, + private Chronos|null $validSince = null, + private Chronos|null $validUntil = null, + private int|null $maxVisits = null, + private Domain|null $domain = null, private bool $customSlugWasProvided = false, private int $shortCodeLength = 0, - public readonly ?ApiKey $authorApiKey = null, - private ?string $title = null, + public readonly ApiKey|null $authorApiKey = null, + private string|null $title = null, private bool $titleWasAutoResolved = false, private bool $crawlable = false, private bool $forwardQuery = true, - private ?string $importSource = null, - private ?string $importOriginalShortCode = null, + private string|null $importSource = null, + private string|null $importOriginalShortCode = null, private Collection $redirectRules = new ArrayCollection(), ) { } @@ -85,7 +85,7 @@ class ShortUrl extends AbstractEntity public static function create( ShortUrlCreation $creation, - ?ShortUrlRelationResolverInterface $relationResolver = null, + ShortUrlRelationResolverInterface|null $relationResolver = null, ): self { $relationResolver = $relationResolver ?? new SimpleShortUrlRelationResolver(); $shortCodeLength = $creation->shortCodeLength; @@ -115,7 +115,7 @@ class ShortUrl extends AbstractEntity public static function fromImport( ImportedShlinkUrl $url, bool $importShortCode, - ?ShortUrlRelationResolverInterface $relationResolver = null, + ShortUrlRelationResolverInterface|null $relationResolver = null, ): self { $meta = [ ShortUrlInputFilter::LONG_URL => $url->longUrl, @@ -141,7 +141,7 @@ class ShortUrl extends AbstractEntity public function update( ShortUrlEdition $shortUrlEdit, - ?ShortUrlRelationResolverInterface $relationResolver = null, + ShortUrlRelationResolverInterface|null $relationResolver = null, ): void { if ($shortUrlEdit->validSinceWasProvided()) { $this->validSince = $shortUrlEdit->validSince; @@ -185,7 +185,7 @@ class ShortUrl extends AbstractEntity return $this->shortCode; } - public function getDomain(): ?Domain + public function getDomain(): Domain|null { return $this->domain; } @@ -195,7 +195,7 @@ class ShortUrl extends AbstractEntity return $this->forwardQuery; } - public function title(): ?string + public function title(): string|null { return $this->title; } @@ -205,7 +205,7 @@ class ShortUrl extends AbstractEntity return count($this->visits) >= $visitsAmount; } - public function mostRecentImportedVisitDate(): ?Chronos + public function mostRecentImportedVisitDate(): Chronos|null { $criteria = Criteria::create()->where(Criteria::expr()->eq('type', VisitType::IMPORTED)) ->orderBy(['id' => 'DESC']) @@ -270,7 +270,7 @@ class ShortUrl extends AbstractEntity * Providing the raw authority as `string|null` would result in a fallback to `$this->domain` when the authority * was null. */ - public function toArray(?VisitsSummary $precalculatedSummary = null, callable|null $getAuthority = null): array + public function toArray(VisitsSummary|null $precalculatedSummary = null, callable|null $getAuthority = null): array { return [ 'shortCode' => $this->shortCode, diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilder.php b/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilder.php index 375d8837..47ac25bf 100644 --- a/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilder.php +++ b/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilder.php @@ -25,7 +25,7 @@ readonly class ShortUrlRedirectionBuilder implements ShortUrlRedirectionBuilderI public function buildShortUrlRedirect( ShortUrl $shortUrl, ServerRequestInterface $request, - ?string $extraPath = null, + string|null $extraPath = null, ): string { $uri = new Uri($this->redirectionResolver->resolveLongUrl($shortUrl, $request)); $shouldForwardQuery = $shortUrl->forwardQuery(); @@ -58,7 +58,7 @@ readonly class ShortUrlRedirectionBuilder implements ShortUrlRedirectionBuilderI return Query::build($mergedQuery); } - private function resolvePath(string $basePath, ?string $extraPath): string + private function resolvePath(string $basePath, string|null $extraPath): string { return $extraPath === null ? $basePath : sprintf('%s%s', $basePath, $extraPath); } diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilderInterface.php b/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilderInterface.php index 7f79e98a..849a3b3f 100644 --- a/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilderInterface.php +++ b/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilderInterface.php @@ -12,6 +12,6 @@ interface ShortUrlRedirectionBuilderInterface public function buildShortUrlRedirect( ShortUrl $shortUrl, ServerRequestInterface $request, - ?string $extraPath = null, + string|null $extraPath = null, ): string; } diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php b/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php index 0950e042..df52c92d 100644 --- a/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php +++ b/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php @@ -61,7 +61,7 @@ readonly class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionH return $title !== null ? $data->withResolvedTitle($title) : $data; } - private function fetchUrl(string $url): ?ResponseInterface + private function fetchUrl(string $url): ResponseInterface|null { try { return $this->httpClient->request(RequestMethodInterface::METHOD_GET, $url, [ @@ -80,7 +80,7 @@ readonly class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionH } } - private function tryToResolveTitle(ResponseInterface $response, string $contentType): ?string + private function tryToResolveTitle(ResponseInterface $response, string $contentType): string|null { $collectedBody = ''; $body = $response->getBody(); diff --git a/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php b/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php index b164ffd6..7c868907 100644 --- a/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php +++ b/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php @@ -47,7 +47,7 @@ class ExtraPathRedirectMiddleware implements MiddlewareInterface return $this->tryToResolveRedirect($request, $handler); } - private function shouldApplyLogic(?NotFoundType $notFoundType): bool + private function shouldApplyLogic(NotFoundType|null $notFoundType): bool { if ($notFoundType === null || ! $this->urlShortenerOptions->appendExtraPath) { return false; diff --git a/module/Core/src/ShortUrl/Model/ShortUrlCreation.php b/module/Core/src/ShortUrl/Model/ShortUrlCreation.php index c9c85e1b..778e8d00 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlCreation.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlCreation.php @@ -26,17 +26,17 @@ final readonly class ShortUrlCreation implements TitleResolutionModelInterface private function __construct( public string $longUrl, public ShortUrlMode $shortUrlMode, - public ?Chronos $validSince = null, - public ?Chronos $validUntil = null, - public ?string $customSlug = null, - public ?string $pathPrefix = null, - public ?int $maxVisits = null, + public Chronos|null $validSince = null, + public Chronos|null $validUntil = null, + public string|null $customSlug = null, + public string|null $pathPrefix = null, + public int|null $maxVisits = null, public bool $findIfExists = false, - public ?string $domain = null, + public string|null $domain = null, public int $shortCodeLength = 5, - public ?ApiKey $apiKey = null, + public ApiKey|null $apiKey = null, public array $tags = [], - public ?string $title = null, + public string|null $title = null, public bool $titleWasAutoResolved = false, public bool $crawlable = false, public bool $forwardQuery = true, diff --git a/module/Core/src/ShortUrl/Model/ShortUrlEdition.php b/module/Core/src/ShortUrl/Model/ShortUrlEdition.php index 6296f84d..69b571d0 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlEdition.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlEdition.php @@ -21,17 +21,17 @@ final readonly class ShortUrlEdition implements TitleResolutionModelInterface */ private function __construct( private bool $longUrlPropWasProvided = false, - public ?string $longUrl = null, + public string|null $longUrl = null, private bool $validSincePropWasProvided = false, - public ?Chronos $validSince = null, + public Chronos|null $validSince = null, private bool $validUntilPropWasProvided = false, - public ?Chronos $validUntil = null, + public Chronos|null $validUntil = null, private bool $maxVisitsPropWasProvided = false, - public ?int $maxVisits = null, + public int|null $maxVisits = null, private bool $tagsPropWasProvided = false, public array $tags = [], private bool $titlePropWasProvided = false, - public ?string $title = null, + public string|null $title = null, public bool $titleWasAutoResolved = false, private bool $crawlablePropWasProvided = false, public bool $crawlable = false, diff --git a/module/Core/src/ShortUrl/Model/ShortUrlIdentifier.php b/module/Core/src/ShortUrl/Model/ShortUrlIdentifier.php index a7c2e2ff..ff44ed7f 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlIdentifier.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlIdentifier.php @@ -11,7 +11,7 @@ use function sprintf; final readonly class ShortUrlIdentifier { - private function __construct(public string $shortCode, public ?string $domain = null) + private function __construct(public string $shortCode, public string|null $domain = null) { } @@ -39,7 +39,7 @@ final readonly class ShortUrlIdentifier return new self($shortUrl->getShortCode(), $domainAuthority); } - public static function fromShortCodeAndDomain(string $shortCode, ?string $domain = null): self + public static function fromShortCodeAndDomain(string $shortCode, string|null $domain = null): self { return new self($shortCode, $domain); } diff --git a/module/Core/src/ShortUrl/Model/ShortUrlsParams.php b/module/Core/src/ShortUrl/Model/ShortUrlsParams.php index e625087e..7b68ed37 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlsParams.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlsParams.php @@ -22,7 +22,7 @@ final class ShortUrlsParams public readonly string|null $searchTerm, public readonly array $tags, public readonly Ordering $orderBy, - public readonly ?DateRange $dateRange, + public readonly DateRange|null $dateRange, public readonly bool $excludeMaxVisitsReached, public readonly bool $excludePastValidUntil, public readonly TagsMode $tagsMode = TagsMode::ANY, @@ -64,7 +64,7 @@ final class ShortUrlsParams ); } - private static function resolveTagsMode(?string $rawTagsMode): TagsMode + private static function resolveTagsMode(string|null $rawTagsMode): TagsMode { if ($rawTagsMode === null) { return TagsMode::ANY; diff --git a/module/Core/src/ShortUrl/Model/UrlShorteningResult.php b/module/Core/src/ShortUrl/Model/UrlShorteningResult.php index b9d4f993..6bfd91bc 100644 --- a/module/Core/src/ShortUrl/Model/UrlShorteningResult.php +++ b/module/Core/src/ShortUrl/Model/UrlShorteningResult.php @@ -11,7 +11,7 @@ final class UrlShorteningResult { private function __construct( public readonly ShortUrl $shortUrl, - private readonly ?Throwable $errorOnEventDispatching, + private readonly Throwable|null $errorOnEventDispatching, ) { } diff --git a/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php b/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php index 3cd7744a..5cd7fe38 100644 --- a/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php +++ b/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php @@ -109,7 +109,7 @@ class ShortUrlInputFilter extends InputFilter $title = InputFactory::basic(self::TITLE); $title->getFilterChain()->attach(new Filter\Callback( - static fn (?string $value) => $value === null ? $value : substr($value, 0, 512), + static fn (string|null $value) => $value === null ? $value : substr($value, 0, 512), )); $this->add($title); diff --git a/module/Core/src/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapter.php b/module/Core/src/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapter.php index ac3379df..1a7b97de 100644 --- a/module/Core/src/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapter.php +++ b/module/Core/src/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapter.php @@ -18,7 +18,7 @@ readonly class ShortUrlRepositoryAdapter implements AdapterInterface public function __construct( private ShortUrlListRepositoryInterface $repository, private ShortUrlsParams $params, - private ?ApiKey $apiKey, + private ApiKey|null $apiKey, private string $defaultDomain, ) { } diff --git a/module/Core/src/ShortUrl/Persistence/ShortUrlsCountFiltering.php b/module/Core/src/ShortUrl/Persistence/ShortUrlsCountFiltering.php index b27fe7c5..a8e42236 100644 --- a/module/Core/src/ShortUrl/Persistence/ShortUrlsCountFiltering.php +++ b/module/Core/src/ShortUrl/Persistence/ShortUrlsCountFiltering.php @@ -17,15 +17,15 @@ class ShortUrlsCountFiltering public readonly bool $searchIncludesDefaultDomain; public function __construct( - public readonly ?string $searchTerm = null, + public readonly string|null $searchTerm = null, public readonly array $tags = [], - public readonly ?TagsMode $tagsMode = null, - public readonly ?DateRange $dateRange = null, + public readonly TagsMode|null $tagsMode = null, + public readonly DateRange|null $dateRange = null, public readonly bool $excludeMaxVisitsReached = false, public readonly bool $excludePastValidUntil = false, - public readonly ?ApiKey $apiKey = null, - ?string $defaultDomain = null, - public readonly ?string $domain = null, + public readonly ApiKey|null $apiKey = null, + string|null $defaultDomain = null, + public readonly string|null $domain = null, ) { $this->searchIncludesDefaultDomain = !empty($searchTerm) && !empty($defaultDomain) && str_contains( strtolower($defaultDomain), @@ -33,7 +33,7 @@ class ShortUrlsCountFiltering ); } - public static function fromParams(ShortUrlsParams $params, ?ApiKey $apiKey, string $defaultDomain): self + public static function fromParams(ShortUrlsParams $params, ApiKey|null $apiKey, string $defaultDomain): self { return new self( $params->searchTerm, diff --git a/module/Core/src/ShortUrl/Persistence/ShortUrlsListFiltering.php b/module/Core/src/ShortUrl/Persistence/ShortUrlsListFiltering.php index b3946ab1..d0fa6418 100644 --- a/module/Core/src/ShortUrl/Persistence/ShortUrlsListFiltering.php +++ b/module/Core/src/ShortUrl/Persistence/ShortUrlsListFiltering.php @@ -13,19 +13,19 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class ShortUrlsListFiltering extends ShortUrlsCountFiltering { public function __construct( - public readonly ?int $limit = null, - public readonly ?int $offset = null, + public readonly int|null $limit = null, + public readonly int|null $offset = null, public readonly Ordering $orderBy = new Ordering(), - ?string $searchTerm = null, + string|null $searchTerm = null, array $tags = [], - ?TagsMode $tagsMode = null, - ?DateRange $dateRange = null, + TagsMode|null $tagsMode = null, + DateRange|null $dateRange = null, bool $excludeMaxVisitsReached = false, bool $excludePastValidUntil = false, - ?ApiKey $apiKey = null, + ApiKey|null $apiKey = null, // Used only to determine if search term includes default domain - ?string $defaultDomain = null, - ?string $domain = null, + string|null $defaultDomain = null, + string|null $domain = null, ) { parent::__construct( $searchTerm, @@ -44,7 +44,7 @@ class ShortUrlsListFiltering extends ShortUrlsCountFiltering int $limit, int $offset, ShortUrlsParams $params, - ?ApiKey $apiKey, + ApiKey|null $apiKey, string $defaultDomain, ): self { return new self( diff --git a/module/Core/src/ShortUrl/Repository/ShortUrlRepository.php b/module/Core/src/ShortUrl/Repository/ShortUrlRepository.php index 015c8eac..bb6abea2 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlRepository.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlRepository.php @@ -23,7 +23,7 @@ use function strtolower; /** @extends EntitySpecificationRepository */ class ShortUrlRepository extends EntitySpecificationRepository implements ShortUrlRepositoryInterface { - public function findOneWithDomainFallback(ShortUrlIdentifier $identifier, ShortUrlMode $shortUrlMode): ?ShortUrl + public function findOneWithDomainFallback(ShortUrlIdentifier $identifier, ShortUrlMode $shortUrlMode): ShortUrl|null { // When ordering DESC, Postgres puts nulls at the beginning while the rest of supported DB engines put them at // the bottom @@ -52,7 +52,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU return $qb->getQuery()->getOneOrNullResult(); } - public function findOne(ShortUrlIdentifier $identifier, ?Specification $spec = null): ?ShortUrl + public function findOne(ShortUrlIdentifier $identifier, Specification|null $spec = null): ShortUrl|null { $qb = $this->createFindOneQueryBuilder($identifier, $spec); $qb->select('s'); @@ -60,12 +60,12 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU return $qb->getQuery()->getOneOrNullResult(); } - public function shortCodeIsInUse(ShortUrlIdentifier $identifier, ?Specification $spec = null): bool + public function shortCodeIsInUse(ShortUrlIdentifier $identifier, Specification|null $spec = null): bool { return $this->doShortCodeIsInUse($identifier, $spec, null); } - public function shortCodeIsInUseWithLock(ShortUrlIdentifier $identifier, ?Specification $spec = null): bool + public function shortCodeIsInUseWithLock(ShortUrlIdentifier $identifier, Specification|null $spec = null): bool { return $this->doShortCodeIsInUse($identifier, $spec, LockMode::PESSIMISTIC_WRITE); } @@ -73,8 +73,11 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU /** * @param LockMode::PESSIMISTIC_WRITE|null $lockMode */ - private function doShortCodeIsInUse(ShortUrlIdentifier $identifier, ?Specification $spec, ?LockMode $lockMode): bool - { + private function doShortCodeIsInUse( + ShortUrlIdentifier $identifier, + Specification|null $spec, + LockMode|null $lockMode, + ): bool { $qb = $this->createFindOneQueryBuilder($identifier, $spec)->select('s.id'); $query = $qb->getQuery(); @@ -85,7 +88,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU return $query->getOneOrNullResult() !== null; } - private function createFindOneQueryBuilder(ShortUrlIdentifier $identifier, ?Specification $spec): QueryBuilder + private function createFindOneQueryBuilder(ShortUrlIdentifier $identifier, Specification|null $spec): QueryBuilder { $qb = $this->getEntityManager()->createQueryBuilder(); $qb->from(ShortUrl::class, 's') @@ -101,7 +104,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU return $qb; } - public function findOneMatching(ShortUrlCreation $creation): ?ShortUrl + public function findOneMatching(ShortUrlCreation $creation): ShortUrl|null { $qb = $this->getEntityManager()->createQueryBuilder(); @@ -166,7 +169,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU } } - public function findOneByImportedUrl(ImportedShlinkUrl $url): ?ShortUrl + public function findOneByImportedUrl(ImportedShlinkUrl $url): ShortUrl|null { $qb = $this->createQueryBuilder('s'); $qb->andWhere($qb->expr()->eq('s.importOriginalShortCode', ':shortCode')) @@ -180,7 +183,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU return $qb->getQuery()->getOneOrNullResult(); } - private function whereDomainIs(QueryBuilder $qb, ?string $domain): void + private function whereDomainIs(QueryBuilder $qb, string|null $domain): void { if ($domain !== null) { $qb->join('s.domain', 'd') diff --git a/module/Core/src/ShortUrl/Repository/ShortUrlRepositoryInterface.php b/module/Core/src/ShortUrl/Repository/ShortUrlRepositoryInterface.php index d0934197..a96d0be8 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlRepositoryInterface.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlRepositoryInterface.php @@ -16,15 +16,18 @@ use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; /** @extends ObjectRepository */ interface ShortUrlRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface { - public function findOneWithDomainFallback(ShortUrlIdentifier $identifier, ShortUrlMode $shortUrlMode): ?ShortUrl; + public function findOneWithDomainFallback( + ShortUrlIdentifier $identifier, + ShortUrlMode $shortUrlMode, + ): ShortUrl|null; - public function findOne(ShortUrlIdentifier $identifier, ?Specification $spec = null): ?ShortUrl; + public function findOne(ShortUrlIdentifier $identifier, Specification|null $spec = null): ShortUrl|null; - public function shortCodeIsInUse(ShortUrlIdentifier $identifier, ?Specification $spec = null): bool; + public function shortCodeIsInUse(ShortUrlIdentifier $identifier, Specification|null $spec = null): bool; - public function shortCodeIsInUseWithLock(ShortUrlIdentifier $identifier, ?Specification $spec = null): bool; + public function shortCodeIsInUseWithLock(ShortUrlIdentifier $identifier, Specification|null $spec = null): bool; - public function findOneMatching(ShortUrlCreation $creation): ?ShortUrl; + public function findOneMatching(ShortUrlCreation $creation): ShortUrl|null; - public function findOneByImportedUrl(ImportedShlinkUrl $url): ?ShortUrl; + public function findOneByImportedUrl(ImportedShlinkUrl $url): ShortUrl|null; } diff --git a/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php b/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php index 94fb314a..2e5f3e15 100644 --- a/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php +++ b/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php @@ -38,7 +38,7 @@ class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInt $this->em->getEventManager()->addEventListener(Events::postFlush, $this); } - public function resolveDomain(?string $domain): ?Domain + public function resolveDomain(string|null $domain): Domain|null { if ($domain === null || $domain === $this->options->defaultDomain) { return null; diff --git a/module/Core/src/ShortUrl/Resolver/ShortUrlRelationResolverInterface.php b/module/Core/src/ShortUrl/Resolver/ShortUrlRelationResolverInterface.php index b5228214..6af627b5 100644 --- a/module/Core/src/ShortUrl/Resolver/ShortUrlRelationResolverInterface.php +++ b/module/Core/src/ShortUrl/Resolver/ShortUrlRelationResolverInterface.php @@ -10,7 +10,7 @@ use Shlinkio\Shlink\Core\Tag\Entity\Tag; interface ShortUrlRelationResolverInterface { - public function resolveDomain(?string $domain): ?Domain; + public function resolveDomain(string|null $domain): Domain|null; /** * @param string[] $tags diff --git a/module/Core/src/ShortUrl/Resolver/SimpleShortUrlRelationResolver.php b/module/Core/src/ShortUrl/Resolver/SimpleShortUrlRelationResolver.php index c1a9d0ab..5702c346 100644 --- a/module/Core/src/ShortUrl/Resolver/SimpleShortUrlRelationResolver.php +++ b/module/Core/src/ShortUrl/Resolver/SimpleShortUrlRelationResolver.php @@ -12,7 +12,7 @@ use function array_map; class SimpleShortUrlRelationResolver implements ShortUrlRelationResolverInterface { - public function resolveDomain(?string $domain): ?Domain + public function resolveDomain(string|null $domain): Domain|null { return $domain !== null ? Domain::withAuthority($domain) : null; } diff --git a/module/Core/src/ShortUrl/ShortUrlListService.php b/module/Core/src/ShortUrl/ShortUrlListService.php index 853a40b9..2a1adb26 100644 --- a/module/Core/src/ShortUrl/ShortUrlListService.php +++ b/module/Core/src/ShortUrl/ShortUrlListService.php @@ -22,7 +22,7 @@ readonly class ShortUrlListService implements ShortUrlListServiceInterface /** * @inheritDoc */ - public function listShortUrls(ShortUrlsParams $params, ?ApiKey $apiKey = null): Paginator + public function listShortUrls(ShortUrlsParams $params, ApiKey|null $apiKey = null): Paginator { $defaultDomain = $this->urlShortenerOptions->defaultDomain; $paginator = new Paginator(new ShortUrlRepositoryAdapter($this->repo, $params, $apiKey, $defaultDomain)); diff --git a/module/Core/src/ShortUrl/ShortUrlListServiceInterface.php b/module/Core/src/ShortUrl/ShortUrlListServiceInterface.php index b83abd4c..a8b8b2cc 100644 --- a/module/Core/src/ShortUrl/ShortUrlListServiceInterface.php +++ b/module/Core/src/ShortUrl/ShortUrlListServiceInterface.php @@ -14,5 +14,5 @@ interface ShortUrlListServiceInterface /** * @return Paginator */ - public function listShortUrls(ShortUrlsParams $params, ?ApiKey $apiKey = null): Paginator; + public function listShortUrls(ShortUrlsParams $params, ApiKey|null $apiKey = null): Paginator; } diff --git a/module/Core/src/ShortUrl/ShortUrlResolver.php b/module/Core/src/ShortUrl/ShortUrlResolver.php index 14727ff5..0f32768d 100644 --- a/module/Core/src/ShortUrl/ShortUrlResolver.php +++ b/module/Core/src/ShortUrl/ShortUrlResolver.php @@ -23,7 +23,7 @@ readonly class ShortUrlResolver implements ShortUrlResolverInterface /** * @throws ShortUrlNotFoundException */ - public function resolveShortUrl(ShortUrlIdentifier $identifier, ?ApiKey $apiKey = null): ShortUrl + public function resolveShortUrl(ShortUrlIdentifier $identifier, ApiKey|null $apiKey = null): ShortUrl { /** @var ShortUrlRepository $shortUrlRepo */ $shortUrlRepo = $this->em->getRepository(ShortUrl::class); diff --git a/module/Core/src/ShortUrl/ShortUrlResolverInterface.php b/module/Core/src/ShortUrl/ShortUrlResolverInterface.php index 9dd522c0..bcf7d40a 100644 --- a/module/Core/src/ShortUrl/ShortUrlResolverInterface.php +++ b/module/Core/src/ShortUrl/ShortUrlResolverInterface.php @@ -14,7 +14,7 @@ interface ShortUrlResolverInterface /** * @throws ShortUrlNotFoundException */ - public function resolveShortUrl(ShortUrlIdentifier $identifier, ?ApiKey $apiKey = null): ShortUrl; + public function resolveShortUrl(ShortUrlIdentifier $identifier, ApiKey|null $apiKey = null): ShortUrl; /** * Resolves a public short URL matching provided identifier. diff --git a/module/Core/src/ShortUrl/ShortUrlService.php b/module/Core/src/ShortUrl/ShortUrlService.php index d75f847d..b2c7e92f 100644 --- a/module/Core/src/ShortUrl/ShortUrlService.php +++ b/module/Core/src/ShortUrl/ShortUrlService.php @@ -29,7 +29,7 @@ readonly class ShortUrlService implements ShortUrlServiceInterface public function updateShortUrl( ShortUrlIdentifier $identifier, ShortUrlEdition $shortUrlEdit, - ?ApiKey $apiKey = null, + ApiKey|null $apiKey = null, ): ShortUrl { if ($shortUrlEdit->longUrlWasProvided()) { $shortUrlEdit = $this->titleResolutionHelper->processTitle($shortUrlEdit); diff --git a/module/Core/src/ShortUrl/ShortUrlServiceInterface.php b/module/Core/src/ShortUrl/ShortUrlServiceInterface.php index c7892f55..fde21a70 100644 --- a/module/Core/src/ShortUrl/ShortUrlServiceInterface.php +++ b/module/Core/src/ShortUrl/ShortUrlServiceInterface.php @@ -18,6 +18,6 @@ interface ShortUrlServiceInterface public function updateShortUrl( ShortUrlIdentifier $identifier, ShortUrlEdition $shortUrlEdit, - ?ApiKey $apiKey = null, + ApiKey|null $apiKey = null, ): ShortUrl; } diff --git a/module/Core/src/ShortUrl/ShortUrlVisitsDeleter.php b/module/Core/src/ShortUrl/ShortUrlVisitsDeleter.php index 8ad6713f..e8a07654 100644 --- a/module/Core/src/ShortUrl/ShortUrlVisitsDeleter.php +++ b/module/Core/src/ShortUrl/ShortUrlVisitsDeleter.php @@ -21,7 +21,7 @@ class ShortUrlVisitsDeleter implements ShortUrlVisitsDeleterInterface /** * @throws ShortUrlNotFoundException */ - public function deleteShortUrlVisits(ShortUrlIdentifier $identifier, ?ApiKey $apiKey = null): BulkDeleteResult + public function deleteShortUrlVisits(ShortUrlIdentifier $identifier, ApiKey|null $apiKey = null): BulkDeleteResult { $shortUrl = $this->resolver->resolveShortUrl($identifier, $apiKey); return new BulkDeleteResult($this->repository->deleteShortUrlVisits($shortUrl)); diff --git a/module/Core/src/ShortUrl/ShortUrlVisitsDeleterInterface.php b/module/Core/src/ShortUrl/ShortUrlVisitsDeleterInterface.php index 46e9fde5..625880dc 100644 --- a/module/Core/src/ShortUrl/ShortUrlVisitsDeleterInterface.php +++ b/module/Core/src/ShortUrl/ShortUrlVisitsDeleterInterface.php @@ -14,5 +14,5 @@ interface ShortUrlVisitsDeleterInterface /** * @throws ShortUrlNotFoundException */ - public function deleteShortUrlVisits(ShortUrlIdentifier $identifier, ?ApiKey $apiKey = null): BulkDeleteResult; + public function deleteShortUrlVisits(ShortUrlIdentifier $identifier, ApiKey|null $apiKey = null): BulkDeleteResult; } diff --git a/module/Core/src/ShortUrl/Spec/BelongsToApiKey.php b/module/Core/src/ShortUrl/Spec/BelongsToApiKey.php index 3c95593c..42f9c722 100644 --- a/module/Core/src/ShortUrl/Spec/BelongsToApiKey.php +++ b/module/Core/src/ShortUrl/Spec/BelongsToApiKey.php @@ -11,7 +11,7 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class BelongsToApiKey extends BaseSpecification { - public function __construct(private ApiKey $apiKey, ?string $context = null) + public function __construct(private ApiKey $apiKey, string|null $context = null) { parent::__construct($context); } diff --git a/module/Core/src/ShortUrl/Spec/BelongsToDomain.php b/module/Core/src/ShortUrl/Spec/BelongsToDomain.php index 33eacec8..4a2aae62 100644 --- a/module/Core/src/ShortUrl/Spec/BelongsToDomain.php +++ b/module/Core/src/ShortUrl/Spec/BelongsToDomain.php @@ -10,7 +10,7 @@ use Happyr\DoctrineSpecification\Specification\BaseSpecification; class BelongsToDomain extends BaseSpecification { - public function __construct(private string $domainId, private ?string $dqlAlias = null) + public function __construct(private string $domainId, private string|null $dqlAlias = null) { parent::__construct(); } diff --git a/module/Core/src/ShortUrl/UrlShortener.php b/module/Core/src/ShortUrl/UrlShortener.php index 4a908c78..a0692e06 100644 --- a/module/Core/src/ShortUrl/UrlShortener.php +++ b/module/Core/src/ShortUrl/UrlShortener.php @@ -64,7 +64,7 @@ class UrlShortener implements UrlShortenerInterface return UrlShorteningResult::withoutErrorOnEventDispatching($newShortUrl); } - private function findExistingShortUrlIfExists(ShortUrlCreation $creation): ?ShortUrl + private function findExistingShortUrlIfExists(ShortUrlCreation $creation): ShortUrl|null { if (! $creation->findIfExists) { return null; diff --git a/module/Core/src/Spec/InDateRange.php b/module/Core/src/Spec/InDateRange.php index 994e6d63..d373a59d 100644 --- a/module/Core/src/Spec/InDateRange.php +++ b/module/Core/src/Spec/InDateRange.php @@ -11,7 +11,7 @@ use Shlinkio\Shlink\Common\Util\DateRange; class InDateRange extends BaseSpecification { - public function __construct(private ?DateRange $dateRange, private string $field = 'date') + public function __construct(private DateRange|null $dateRange, private string $field = 'date') { parent::__construct(); } diff --git a/module/Core/src/Tag/Model/OrderableField.php b/module/Core/src/Tag/Model/OrderableField.php index 39092e4d..0b7a4272 100644 --- a/module/Core/src/Tag/Model/OrderableField.php +++ b/module/Core/src/Tag/Model/OrderableField.php @@ -11,7 +11,7 @@ enum OrderableField: string case VISITS = 'visits'; case NON_BOT_VISITS = 'nonBotVisits'; - public static function toValidField(?string $field): self + public static function toValidField(string|null $field): self { if ($field === null) { return self::TAG; diff --git a/module/Core/src/Tag/Model/TagInfo.php b/module/Core/src/Tag/Model/TagInfo.php index 504181ec..dfa255bd 100644 --- a/module/Core/src/Tag/Model/TagInfo.php +++ b/module/Core/src/Tag/Model/TagInfo.php @@ -15,7 +15,7 @@ final readonly class TagInfo implements JsonSerializable public string $tag, public int $shortUrlsCount, int $visitsCount, - ?int $nonBotVisitsCount = null, + int|null $nonBotVisitsCount = null, ) { $this->visitsSummary = VisitsSummary::fromTotalAndNonBots($visitsCount, $nonBotVisitsCount ?? $visitsCount); } diff --git a/module/Core/src/Tag/Model/TagsListFiltering.php b/module/Core/src/Tag/Model/TagsListFiltering.php index 236dde4a..d8da71b7 100644 --- a/module/Core/src/Tag/Model/TagsListFiltering.php +++ b/module/Core/src/Tag/Model/TagsListFiltering.php @@ -10,15 +10,15 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; final class TagsListFiltering { public function __construct( - public readonly ?int $limit = null, - public readonly ?int $offset = null, - public readonly ?string $searchTerm = null, - public readonly ?Ordering $orderBy = null, - public readonly ?ApiKey $apiKey = null, + public readonly int|null $limit = null, + public readonly int|null $offset = null, + public readonly string|null $searchTerm = null, + public readonly Ordering|null $orderBy = null, + public readonly ApiKey|null $apiKey = null, ) { } - public static function fromRangeAndParams(int $limit, int $offset, TagsParams $params, ?ApiKey $apiKey): self + public static function fromRangeAndParams(int $limit, int $offset, TagsParams $params, ApiKey|null $apiKey): self { return new self($limit, $offset, $params->searchTerm, $params->orderBy, $apiKey); } diff --git a/module/Core/src/Tag/Model/TagsParams.php b/module/Core/src/Tag/Model/TagsParams.php index d094bcc0..7207b1a8 100644 --- a/module/Core/src/Tag/Model/TagsParams.php +++ b/module/Core/src/Tag/Model/TagsParams.php @@ -12,10 +12,10 @@ use function Shlinkio\Shlink\Common\parseOrderBy; final class TagsParams extends AbstractInfinitePaginableListParams { private function __construct( - public readonly ?string $searchTerm, + public readonly string|null $searchTerm, public readonly Ordering $orderBy, - ?int $page, - ?int $itemsPerPage, + int|null $page, + int|null $itemsPerPage, ) { parent::__construct($page, $itemsPerPage); } diff --git a/module/Core/src/Tag/Paginator/Adapter/AbstractTagsPaginatorAdapter.php b/module/Core/src/Tag/Paginator/Adapter/AbstractTagsPaginatorAdapter.php index 98126e27..e26ba2f4 100644 --- a/module/Core/src/Tag/Paginator/Adapter/AbstractTagsPaginatorAdapter.php +++ b/module/Core/src/Tag/Paginator/Adapter/AbstractTagsPaginatorAdapter.php @@ -21,7 +21,7 @@ abstract class AbstractTagsPaginatorAdapter implements AdapterInterface public function __construct( protected TagRepositoryInterface $repo, protected TagsParams $params, - protected ?ApiKey $apiKey, + protected ApiKey|null $apiKey, ) { } diff --git a/module/Core/src/Tag/Repository/TagRepository.php b/module/Core/src/Tag/Repository/TagRepository.php index a2820e7b..4545e46c 100644 --- a/module/Core/src/Tag/Repository/TagRepository.php +++ b/module/Core/src/Tag/Repository/TagRepository.php @@ -42,7 +42,7 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito /** * @return TagInfo[] */ - public function findTagsWithInfo(?TagsListFiltering $filtering = null): array + public function findTagsWithInfo(TagsListFiltering|null $filtering = null): array { $orderField = OrderableField::toValidField($filtering?->orderBy?->field); $orderDir = $filtering?->orderBy?->direction ?? 'ASC'; @@ -134,7 +134,7 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito ); } - public function tagExists(string $tag, ?ApiKey $apiKey = null): bool + public function tagExists(string $tag, ApiKey|null $apiKey = null): bool { $result = (int) $this->matchSingleScalarResult(Spec::andX( new CountTagsWithName($tag), diff --git a/module/Core/src/Tag/Repository/TagRepositoryInterface.php b/module/Core/src/Tag/Repository/TagRepositoryInterface.php index ccb33de0..236beb14 100644 --- a/module/Core/src/Tag/Repository/TagRepositoryInterface.php +++ b/module/Core/src/Tag/Repository/TagRepositoryInterface.php @@ -19,7 +19,7 @@ interface TagRepositoryInterface extends ObjectRepository, EntitySpecificationRe /** * @return TagInfo[] */ - public function findTagsWithInfo(?TagsListFiltering $filtering = null): array; + public function findTagsWithInfo(TagsListFiltering|null $filtering = null): array; - public function tagExists(string $tag, ?ApiKey $apiKey = null): bool; + public function tagExists(string $tag, ApiKey|null $apiKey = null): bool; } diff --git a/module/Core/src/Tag/TagService.php b/module/Core/src/Tag/TagService.php index de16fada..e3e5b92f 100644 --- a/module/Core/src/Tag/TagService.php +++ b/module/Core/src/Tag/TagService.php @@ -28,7 +28,7 @@ readonly class TagService implements TagServiceInterface /** * @inheritDoc */ - public function listTags(TagsParams $params, ?ApiKey $apiKey = null): Paginator + public function listTags(TagsParams $params, ApiKey|null $apiKey = null): Paginator { /** @var TagRepository $repo */ $repo = $this->em->getRepository(Tag::class); @@ -38,7 +38,7 @@ readonly class TagService implements TagServiceInterface /** * @inheritDoc */ - public function tagsInfo(TagsParams $params, ?ApiKey $apiKey = null): Paginator + public function tagsInfo(TagsParams $params, ApiKey|null $apiKey = null): Paginator { /** @var TagRepositoryInterface $repo */ $repo = $this->em->getRepository(Tag::class); @@ -60,7 +60,7 @@ readonly class TagService implements TagServiceInterface /** * @inheritDoc */ - public function deleteTags(array $tagNames, ?ApiKey $apiKey = null): void + public function deleteTags(array $tagNames, ApiKey|null $apiKey = null): void { if (ApiKey::isShortUrlRestricted($apiKey)) { throw ForbiddenTagOperationException::forDeletion(); @@ -74,7 +74,7 @@ readonly class TagService implements TagServiceInterface /** * @inheritDoc */ - public function renameTag(TagRenaming $renaming, ?ApiKey $apiKey = null): Tag + public function renameTag(TagRenaming $renaming, ApiKey|null $apiKey = null): Tag { if (ApiKey::isShortUrlRestricted($apiKey)) { throw ForbiddenTagOperationException::forRenaming(); diff --git a/module/Core/src/Tag/TagServiceInterface.php b/module/Core/src/Tag/TagServiceInterface.php index 60aeb7c7..c09370cf 100644 --- a/module/Core/src/Tag/TagServiceInterface.php +++ b/module/Core/src/Tag/TagServiceInterface.php @@ -19,23 +19,23 @@ interface TagServiceInterface /** * @return Paginator */ - public function listTags(TagsParams $params, ?ApiKey $apiKey = null): Paginator; + public function listTags(TagsParams $params, ApiKey|null $apiKey = null): Paginator; /** * @return Paginator */ - public function tagsInfo(TagsParams $params, ?ApiKey $apiKey = null): Paginator; + public function tagsInfo(TagsParams $params, ApiKey|null $apiKey = null): Paginator; /** * @param string[] $tagNames * @throws ForbiddenTagOperationException */ - public function deleteTags(array $tagNames, ?ApiKey $apiKey = null): void; + public function deleteTags(array $tagNames, ApiKey|null $apiKey = null): void; /** * @throws TagNotFoundException * @throws TagConflictException * @throws ForbiddenTagOperationException */ - public function renameTag(TagRenaming $renaming, ?ApiKey $apiKey = null): Tag; + public function renameTag(TagRenaming $renaming, ApiKey|null $apiKey = null): Tag; } diff --git a/module/Core/src/Util/IpAddressUtils.php b/module/Core/src/Util/IpAddressUtils.php index 66354c37..9adfa97d 100644 --- a/module/Core/src/Util/IpAddressUtils.php +++ b/module/Core/src/Util/IpAddressUtils.php @@ -56,7 +56,7 @@ final class IpAddressUtils * * @param string[] $ipAddressParts */ - private static function candidateToRange(string $candidate, array $ipAddressParts): ?RangeInterface + private static function candidateToRange(string $candidate, array $ipAddressParts): RangeInterface|null { return str_contains($candidate, '*') ? self::parseValueWithWildcards($candidate, $ipAddressParts) @@ -68,7 +68,7 @@ final class IpAddressUtils * Factory::parseRangeString can usually do this automatically, but only if wildcards are at the end. This also * covers cases where wildcards are in between. */ - private static function parseValueWithWildcards(string $value, array $ipAddressParts): ?RangeInterface + private static function parseValueWithWildcards(string $value, array $ipAddressParts): RangeInterface|null { $octets = explode('.', $value); $keys = array_keys($octets); diff --git a/module/Core/src/Visit/Entity/Visit.php b/module/Core/src/Visit/Entity/Visit.php index be8400dc..d02d7298 100644 --- a/module/Core/src/Visit/Entity/Visit.php +++ b/module/Core/src/Visit/Entity/Visit.php @@ -21,14 +21,14 @@ use function Shlinkio\Shlink\Core\normalizeDate; class Visit extends AbstractEntity implements JsonSerializable { private function __construct( - public readonly ?ShortUrl $shortUrl, + public readonly ShortUrl|null $shortUrl, public readonly VisitType $type, public readonly string $userAgent, public readonly string $referer, public readonly bool $potentialBot, - public readonly ?string $remoteAddr = null, - public readonly ?string $visitedUrl = null, - private ?VisitLocation $visitLocation = null, + public readonly string|null $remoteAddr = null, + public readonly string|null $visitedUrl = null, + private VisitLocation|null $visitLocation = null, public readonly Chronos $date = new Chronos(), ) { } @@ -53,8 +53,12 @@ class Visit extends AbstractEntity implements JsonSerializable return self::fromVisitor(null, VisitType::REGULAR_404, $visitor, $anonymize); } - private static function fromVisitor(?ShortUrl $shortUrl, VisitType $type, Visitor $visitor, bool $anonymize): self - { + private static function fromVisitor( + ShortUrl|null $shortUrl, + VisitType $type, + Visitor $visitor, + bool $anonymize, + ): self { return new self( shortUrl: $shortUrl, type: $type, @@ -66,7 +70,7 @@ class Visit extends AbstractEntity implements JsonSerializable ); } - private static function processAddress(?string $address, bool $anonymize): ?string + private static function processAddress(string|null $address, bool $anonymize): string|null { // Localhost address does not need to be anonymized if (! $anonymize || $address === null || $address === IpAddress::LOCALHOST) { @@ -96,7 +100,7 @@ class Visit extends AbstractEntity implements JsonSerializable private static function fromImportOrOrphanImport( ImportedShlinkVisit|ImportedShlinkOrphanVisit $importedVisit, VisitType $type, - ?ShortUrl $shortUrl = null, + ShortUrl|null $shortUrl = null, ): self { $importedLocation = $importedVisit->location; return new self( @@ -116,7 +120,7 @@ class Visit extends AbstractEntity implements JsonSerializable return ! empty($this->remoteAddr); } - public function getVisitLocation(): ?VisitLocation + public function getVisitLocation(): VisitLocation|null { return $this->visitLocation; } diff --git a/module/Core/src/Visit/Model/OrphanVisitsParams.php b/module/Core/src/Visit/Model/OrphanVisitsParams.php index 0fb2e99b..0e6afedc 100644 --- a/module/Core/src/Visit/Model/OrphanVisitsParams.php +++ b/module/Core/src/Visit/Model/OrphanVisitsParams.php @@ -12,11 +12,11 @@ use function sprintf; final class OrphanVisitsParams extends VisitsParams { public function __construct( - ?DateRange $dateRange = null, - ?int $page = null, - ?int $itemsPerPage = null, + DateRange|null $dateRange = null, + int|null $page = null, + int|null $itemsPerPage = null, bool $excludeBots = false, - public readonly ?OrphanVisitType $type = null, + public readonly OrphanVisitType|null $type = null, ) { parent::__construct($dateRange, $page, $itemsPerPage, $excludeBots); } diff --git a/module/Core/src/Visit/Model/Visitor.php b/module/Core/src/Visit/Model/Visitor.php index ca5d79b2..c914f334 100644 --- a/module/Core/src/Visit/Model/Visitor.php +++ b/module/Core/src/Visit/Model/Visitor.php @@ -21,10 +21,10 @@ final class Visitor public readonly string $userAgent; public readonly string $referer; public readonly string $visitedUrl; - public readonly ?string $remoteAddress; + public readonly string|null $remoteAddress; private bool $potentialBot; - public function __construct(string $userAgent, string $referer, ?string $remoteAddress, string $visitedUrl) + public function __construct(string $userAgent, string $referer, string|null $remoteAddress, string $visitedUrl) { $this->userAgent = $this->cropToLength($userAgent, self::USER_AGENT_MAX_LENGTH); $this->referer = $this->cropToLength($referer, self::REFERER_MAX_LENGTH); diff --git a/module/Core/src/Visit/Model/VisitsParams.php b/module/Core/src/Visit/Model/VisitsParams.php index 10713131..31e6e67d 100644 --- a/module/Core/src/Visit/Model/VisitsParams.php +++ b/module/Core/src/Visit/Model/VisitsParams.php @@ -14,9 +14,9 @@ class VisitsParams extends AbstractInfinitePaginableListParams public readonly DateRange $dateRange; public function __construct( - ?DateRange $dateRange = null, - ?int $page = null, - ?int $itemsPerPage = null, + DateRange|null $dateRange = null, + int|null $page = null, + int|null $itemsPerPage = null, public readonly bool $excludeBots = false, ) { parent::__construct($page, $itemsPerPage); diff --git a/module/Core/src/Visit/Model/VisitsStats.php b/module/Core/src/Visit/Model/VisitsStats.php index 22f05bd4..2f812aef 100644 --- a/module/Core/src/Visit/Model/VisitsStats.php +++ b/module/Core/src/Visit/Model/VisitsStats.php @@ -14,8 +14,8 @@ final readonly class VisitsStats implements JsonSerializable public function __construct( int $nonOrphanVisitsTotal, int $orphanVisitsTotal, - ?int $nonOrphanVisitsNonBots = null, - ?int $orphanVisitsNonBots = null, + int|null $nonOrphanVisitsNonBots = null, + int|null $orphanVisitsNonBots = null, ) { $this->nonOrphanVisitsSummary = VisitsSummary::fromTotalAndNonBots( $nonOrphanVisitsTotal, diff --git a/module/Core/src/Visit/Paginator/Adapter/DomainVisitsPaginatorAdapter.php b/module/Core/src/Visit/Paginator/Adapter/DomainVisitsPaginatorAdapter.php index 330d8692..184ecdd1 100644 --- a/module/Core/src/Visit/Paginator/Adapter/DomainVisitsPaginatorAdapter.php +++ b/module/Core/src/Visit/Paginator/Adapter/DomainVisitsPaginatorAdapter.php @@ -21,7 +21,7 @@ class DomainVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte private readonly VisitRepositoryInterface $visitRepository, private readonly string $domain, private readonly VisitsParams $params, - private readonly ?ApiKey $apiKey, + private readonly ApiKey|null $apiKey, ) { } diff --git a/module/Core/src/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapter.php b/module/Core/src/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapter.php index 7929bcfd..5e3cdbe1 100644 --- a/module/Core/src/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapter.php +++ b/module/Core/src/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapter.php @@ -18,7 +18,7 @@ class NonOrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAda public function __construct( private readonly VisitRepositoryInterface $repo, private readonly VisitsParams $params, - private readonly ?ApiKey $apiKey, + private readonly ApiKey|null $apiKey, ) { } diff --git a/module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php b/module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php index 9deedf9a..899ab831 100644 --- a/module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php +++ b/module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php @@ -18,7 +18,7 @@ class OrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte public function __construct( private readonly VisitRepositoryInterface $repo, private readonly OrphanVisitsParams $params, - private readonly ?ApiKey $apiKey, + private readonly ApiKey|null $apiKey, ) { } diff --git a/module/Core/src/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapter.php b/module/Core/src/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapter.php index 43bc02ff..efd68035 100644 --- a/module/Core/src/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapter.php +++ b/module/Core/src/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapter.php @@ -20,7 +20,7 @@ class ShortUrlVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdap private readonly VisitRepositoryInterface $visitRepository, private readonly ShortUrlIdentifier $identifier, private readonly VisitsParams $params, - private readonly ?ApiKey $apiKey, + private readonly ApiKey|null $apiKey, ) { } diff --git a/module/Core/src/Visit/Paginator/Adapter/TagVisitsPaginatorAdapter.php b/module/Core/src/Visit/Paginator/Adapter/TagVisitsPaginatorAdapter.php index 93a182bd..909bd2ba 100644 --- a/module/Core/src/Visit/Paginator/Adapter/TagVisitsPaginatorAdapter.php +++ b/module/Core/src/Visit/Paginator/Adapter/TagVisitsPaginatorAdapter.php @@ -19,7 +19,7 @@ class TagVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter private readonly VisitRepositoryInterface $visitRepository, private readonly string $tag, private readonly VisitsParams $params, - private readonly ?ApiKey $apiKey, + private readonly ApiKey|null $apiKey, ) { } diff --git a/module/Core/src/Visit/Persistence/OrphanVisitsCountFiltering.php b/module/Core/src/Visit/Persistence/OrphanVisitsCountFiltering.php index 88676df8..c09bc5ca 100644 --- a/module/Core/src/Visit/Persistence/OrphanVisitsCountFiltering.php +++ b/module/Core/src/Visit/Persistence/OrphanVisitsCountFiltering.php @@ -11,10 +11,10 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class OrphanVisitsCountFiltering extends VisitsCountFiltering { public function __construct( - ?DateRange $dateRange = null, + DateRange|null $dateRange = null, bool $excludeBots = false, - ?ApiKey $apiKey = null, - public readonly ?OrphanVisitType $type = null, + ApiKey|null $apiKey = null, + public readonly OrphanVisitType|null $type = null, ) { parent::__construct($dateRange, $excludeBots, $apiKey); } diff --git a/module/Core/src/Visit/Persistence/OrphanVisitsListFiltering.php b/module/Core/src/Visit/Persistence/OrphanVisitsListFiltering.php index c2873cdf..d1e49605 100644 --- a/module/Core/src/Visit/Persistence/OrphanVisitsListFiltering.php +++ b/module/Core/src/Visit/Persistence/OrphanVisitsListFiltering.php @@ -11,12 +11,12 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; final class OrphanVisitsListFiltering extends OrphanVisitsCountFiltering { public function __construct( - ?DateRange $dateRange = null, + DateRange|null $dateRange = null, bool $excludeBots = false, - ?ApiKey $apiKey = null, - ?OrphanVisitType $type = null, - public readonly ?int $limit = null, - public readonly ?int $offset = null, + ApiKey|null $apiKey = null, + OrphanVisitType|null $type = null, + public readonly int|null $limit = null, + public readonly int|null $offset = null, ) { parent::__construct($dateRange, $excludeBots, $apiKey, $type); } diff --git a/module/Core/src/Visit/Persistence/VisitsCountFiltering.php b/module/Core/src/Visit/Persistence/VisitsCountFiltering.php index 570abc19..8948c960 100644 --- a/module/Core/src/Visit/Persistence/VisitsCountFiltering.php +++ b/module/Core/src/Visit/Persistence/VisitsCountFiltering.php @@ -10,9 +10,9 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class VisitsCountFiltering { public function __construct( - public readonly ?DateRange $dateRange = null, + public readonly DateRange|null $dateRange = null, public readonly bool $excludeBots = false, - public readonly ?ApiKey $apiKey = null, + public readonly ApiKey|null $apiKey = null, ) { } } diff --git a/module/Core/src/Visit/Persistence/VisitsListFiltering.php b/module/Core/src/Visit/Persistence/VisitsListFiltering.php index 747a3ce0..eded82eb 100644 --- a/module/Core/src/Visit/Persistence/VisitsListFiltering.php +++ b/module/Core/src/Visit/Persistence/VisitsListFiltering.php @@ -10,11 +10,11 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; final class VisitsListFiltering extends VisitsCountFiltering { public function __construct( - ?DateRange $dateRange = null, + DateRange|null $dateRange = null, bool $excludeBots = false, - ?ApiKey $apiKey = null, - public readonly ?int $limit = null, - public readonly ?int $offset = null, + ApiKey|null $apiKey = null, + public readonly int|null $limit = null, + public readonly int|null $offset = null, ) { parent::__construct($dateRange, $excludeBots, $apiKey); } diff --git a/module/Core/src/Visit/Repository/VisitIterationRepository.php b/module/Core/src/Visit/Repository/VisitIterationRepository.php index 71590d7e..1370ed20 100644 --- a/module/Core/src/Visit/Repository/VisitIterationRepository.php +++ b/module/Core/src/Visit/Repository/VisitIterationRepository.php @@ -48,7 +48,7 @@ class VisitIterationRepository extends EntitySpecificationRepository implements /** * @return iterable */ - public function findAllVisits(?DateRange $dateRange = null, int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable + public function findAllVisits(DateRange|null $dateRange = null, int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable { $qb = $this->createQueryBuilder('v'); if ($dateRange?->startDate !== null) { diff --git a/module/Core/src/Visit/Repository/VisitIterationRepositoryInterface.php b/module/Core/src/Visit/Repository/VisitIterationRepositoryInterface.php index d4ffb864..2f416324 100644 --- a/module/Core/src/Visit/Repository/VisitIterationRepositoryInterface.php +++ b/module/Core/src/Visit/Repository/VisitIterationRepositoryInterface.php @@ -24,5 +24,8 @@ interface VisitIterationRepositoryInterface /** * @return iterable */ - public function findAllVisits(?DateRange $dateRange = null, int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable; + public function findAllVisits( + DateRange|null $dateRange = null, + int $blockSize = self::DEFAULT_BLOCK_SIZE, + ): iterable; } diff --git a/module/Core/src/Visit/Repository/VisitRepository.php b/module/Core/src/Visit/Repository/VisitRepository.php index 1df109b3..1c85fe66 100644 --- a/module/Core/src/Visit/Repository/VisitRepository.php +++ b/module/Core/src/Visit/Repository/VisitRepository.php @@ -203,7 +203,7 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo return $qb; } - private function applyDatesInline(QueryBuilder $qb, ?DateRange $dateRange): void + private function applyDatesInline(QueryBuilder $qb, DateRange|null $dateRange): void { $conn = $this->getEntityManager()->getConnection(); @@ -215,7 +215,7 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo } } - private function resolveVisitsWithNativeQuery(QueryBuilder $qb, ?int $limit, ?int $offset): array + private function resolveVisitsWithNativeQuery(QueryBuilder $qb, int|null $limit, int|null $offset): array { // TODO Order by date and ID, not just by ID (order by date DESC, id DESC). // That ensures imported visits are properly ordered even if inserted in wrong chronological order. @@ -248,7 +248,7 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo return $this->getEntityManager()->createNativeQuery($nativeQb->getSQL(), $rsm)->getResult(); } - public function findMostRecentOrphanVisit(): ?Visit + public function findMostRecentOrphanVisit(): Visit|null { $dql = <<trackingOptions->queryHasDisableTrackParam($query); } - private function shouldDisableTrackingFromAddress(?string $remoteAddr): bool + private function shouldDisableTrackingFromAddress(string|null $remoteAddr): bool { if ($remoteAddr === null || ! $this->trackingOptions->hasDisableTrackingFrom()) { return false; diff --git a/module/Core/src/Visit/VisitsDeleter.php b/module/Core/src/Visit/VisitsDeleter.php index 2b925e17..fb0f231a 100644 --- a/module/Core/src/Visit/VisitsDeleter.php +++ b/module/Core/src/Visit/VisitsDeleter.php @@ -15,7 +15,7 @@ class VisitsDeleter implements VisitsDeleterInterface { } - public function deleteOrphanVisits(?ApiKey $apiKey = null): BulkDeleteResult + public function deleteOrphanVisits(ApiKey|null $apiKey = null): BulkDeleteResult { $affectedItems = $apiKey?->hasRole(Role::NO_ORPHAN_VISITS) ? 0 : $this->repository->deleteOrphanVisits(); return new BulkDeleteResult($affectedItems); diff --git a/module/Core/src/Visit/VisitsDeleterInterface.php b/module/Core/src/Visit/VisitsDeleterInterface.php index 3a75a0d3..67fa5e72 100644 --- a/module/Core/src/Visit/VisitsDeleterInterface.php +++ b/module/Core/src/Visit/VisitsDeleterInterface.php @@ -9,5 +9,5 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; interface VisitsDeleterInterface { - public function deleteOrphanVisits(?ApiKey $apiKey = null): BulkDeleteResult; + public function deleteOrphanVisits(ApiKey|null $apiKey = null): BulkDeleteResult; } diff --git a/module/Core/src/Visit/VisitsStatsHelper.php b/module/Core/src/Visit/VisitsStatsHelper.php index 0952670b..f1533ddf 100644 --- a/module/Core/src/Visit/VisitsStatsHelper.php +++ b/module/Core/src/Visit/VisitsStatsHelper.php @@ -41,7 +41,7 @@ readonly class VisitsStatsHelper implements VisitsStatsHelperInterface { } - public function getVisitsStats(?ApiKey $apiKey = null): VisitsStats + public function getVisitsStats(ApiKey|null $apiKey = null): VisitsStats { /** @var OrphanVisitsCountRepository $orphanVisitsCountRepo */ $orphanVisitsCountRepo = $this->em->getRepository(OrphanVisitsCount::class); @@ -68,7 +68,7 @@ readonly class VisitsStatsHelper implements VisitsStatsHelperInterface public function visitsForShortUrl( ShortUrlIdentifier $identifier, VisitsParams $params, - ?ApiKey $apiKey = null, + ApiKey|null $apiKey = null, ): Paginator { /** @var ShortUrlRepositoryInterface $repo */ $repo = $this->em->getRepository(ShortUrl::class); @@ -88,7 +88,7 @@ readonly class VisitsStatsHelper implements VisitsStatsHelperInterface /** * @inheritDoc */ - public function visitsForTag(string $tag, VisitsParams $params, ?ApiKey $apiKey = null): Paginator + public function visitsForTag(string $tag, VisitsParams $params, ApiKey|null $apiKey = null): Paginator { /** @var TagRepository $tagRepo */ $tagRepo = $this->em->getRepository(Tag::class); @@ -105,7 +105,7 @@ readonly class VisitsStatsHelper implements VisitsStatsHelperInterface /** * @inheritDoc */ - public function visitsForDomain(string $domain, VisitsParams $params, ?ApiKey $apiKey = null): Paginator + public function visitsForDomain(string $domain, VisitsParams $params, ApiKey|null $apiKey = null): Paginator { /** @var DomainRepository $domainRepo */ $domainRepo = $this->em->getRepository(Domain::class); @@ -122,7 +122,7 @@ readonly class VisitsStatsHelper implements VisitsStatsHelperInterface /** * @inheritDoc */ - public function orphanVisits(OrphanVisitsParams $params, ?ApiKey $apiKey = null): Paginator + public function orphanVisits(OrphanVisitsParams $params, ApiKey|null $apiKey = null): Paginator { /** @var VisitRepositoryInterface $repo */ $repo = $this->em->getRepository(Visit::class); @@ -130,7 +130,7 @@ readonly class VisitsStatsHelper implements VisitsStatsHelperInterface return $this->createPaginator(new OrphanVisitsPaginatorAdapter($repo, $params, $apiKey), $params); } - public function nonOrphanVisits(VisitsParams $params, ?ApiKey $apiKey = null): Paginator + public function nonOrphanVisits(VisitsParams $params, ApiKey|null $apiKey = null): Paginator { /** @var VisitRepositoryInterface $repo */ $repo = $this->em->getRepository(Visit::class); diff --git a/module/Core/src/Visit/VisitsStatsHelperInterface.php b/module/Core/src/Visit/VisitsStatsHelperInterface.php index 87e0980b..12e58933 100644 --- a/module/Core/src/Visit/VisitsStatsHelperInterface.php +++ b/module/Core/src/Visit/VisitsStatsHelperInterface.php @@ -17,7 +17,7 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; interface VisitsStatsHelperInterface { - public function getVisitsStats(?ApiKey $apiKey = null): VisitsStats; + public function getVisitsStats(ApiKey|null $apiKey = null): VisitsStats; /** * @return Paginator @@ -26,28 +26,28 @@ interface VisitsStatsHelperInterface public function visitsForShortUrl( ShortUrlIdentifier $identifier, VisitsParams $params, - ?ApiKey $apiKey = null, + ApiKey|null $apiKey = null, ): Paginator; /** * @return Paginator * @throws TagNotFoundException */ - public function visitsForTag(string $tag, VisitsParams $params, ?ApiKey $apiKey = null): Paginator; + public function visitsForTag(string $tag, VisitsParams $params, ApiKey|null $apiKey = null): Paginator; /** * @return Paginator * @throws DomainNotFoundException */ - public function visitsForDomain(string $domain, VisitsParams $params, ?ApiKey $apiKey = null): Paginator; + public function visitsForDomain(string $domain, VisitsParams $params, ApiKey|null $apiKey = null): Paginator; /** * @return Paginator */ - public function orphanVisits(OrphanVisitsParams $params, ?ApiKey $apiKey = null): Paginator; + public function orphanVisits(OrphanVisitsParams $params, ApiKey|null $apiKey = null): Paginator; /** * @return Paginator */ - public function nonOrphanVisits(VisitsParams $params, ?ApiKey $apiKey = null): Paginator; + public function nonOrphanVisits(VisitsParams $params, ApiKey|null $apiKey = null): Paginator; } diff --git a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php index 58817f38..0bae6bd8 100644 --- a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php +++ b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php @@ -128,7 +128,7 @@ class DomainRepositoryTest extends DatabaseTestCase self::assertFalse($this->repo->domainExists('foo.com', $detachedWithRedirectsApiKey)); } - private function createShortUrl(Domain $domain, ?ApiKey $apiKey = null): ShortUrl + private function createShortUrl(Domain $domain, ApiKey|null $apiKey = null): ShortUrl { return ShortUrl::create( ShortUrlCreation::fromRawData( @@ -139,7 +139,7 @@ class DomainRepositoryTest extends DatabaseTestCase { } - public function resolveDomain(?string $domain): ?Domain + public function resolveDomain(string|null $domain): Domain|null { return $this->domain; } diff --git a/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php b/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php index 074acdd4..535ca50f 100644 --- a/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php +++ b/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php @@ -404,7 +404,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase #[Test] public function importedShortUrlsAreFoundWhenExpected(): void { - $buildImported = static fn (string $shortCode, ?string $domain = null) => + $buildImported = static fn (string $shortCode, string|null $domain = null) => new ImportedShlinkUrl(ImportSource::BITLY, 'https://foo', [], Chronos::now(), $domain, $shortCode, null); $shortUrlWithoutDomain = ShortUrl::fromImport($buildImported('my-cool-slug'), true); diff --git a/module/Core/test-db/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php b/module/Core/test-db/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php index f88a8e7f..b7027f97 100644 --- a/module/Core/test-db/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php +++ b/module/Core/test-db/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php @@ -29,8 +29,8 @@ class TagsPaginatorAdapterTest extends DatabaseTestCase */ #[Test, DataProvider('provideFilters')] public function expectedListOfTagsIsReturned( - ?string $searchTerm, - ?string $orderBy, + string|null $searchTerm, + string|null $orderBy, int $offset, int $length, array $expectedTags, diff --git a/module/Core/test-db/Tag/Repository/TagRepositoryTest.php b/module/Core/test-db/Tag/Repository/TagRepositoryTest.php index 77f6aa6a..34210dbe 100644 --- a/module/Core/test-db/Tag/Repository/TagRepositoryTest.php +++ b/module/Core/test-db/Tag/Repository/TagRepositoryTest.php @@ -57,7 +57,7 @@ class TagRepositoryTest extends DatabaseTestCase } #[Test, DataProvider('provideFilters')] - public function properTagsInfoIsReturned(?TagsListFiltering $filtering, array $expectedList): void + public function properTagsInfoIsReturned(TagsListFiltering|null $filtering, array $expectedList): void { $names = ['foo', 'bar', 'baz', 'another']; foreach ($names as $name) { @@ -73,7 +73,7 @@ class TagRepositoryTest extends DatabaseTestCase [$firstUrlTags] = array_chunk($names, 3); $secondUrlTags = [$names[0]]; - $metaWithTags = static fn (array $tags, ?ApiKey $apiKey) => ShortUrlCreation::fromRawData( + $metaWithTags = static fn (array $tags, ApiKey|null $apiKey) => ShortUrlCreation::fromRawData( ['longUrl' => 'https://longUrl', 'tags' => $tags, 'apiKey' => $apiKey], ); diff --git a/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php b/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php index 8d7579b7..393e41da 100644 --- a/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php +++ b/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php @@ -534,7 +534,7 @@ class VisitRepositoryTest extends DatabaseTestCase private function createShortUrlsAndVisits( bool|string $withDomain = true, array $tags = [], - ?ApiKey $apiKey = null, + ApiKey|null $apiKey = null, ): array { $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([ ShortUrlInputFilter::LONG_URL => 'https://longUrl', diff --git a/module/Core/test/Action/QrCodeActionTest.php b/module/Core/test/Action/QrCodeActionTest.php index 5e499403..f8dea217 100644 --- a/module/Core/test/Action/QrCodeActionTest.php +++ b/module/Core/test/Action/QrCodeActionTest.php @@ -190,7 +190,7 @@ class QrCodeActionTest extends TestCase #[Test, DataProvider('provideRoundBlockSize')] public function imageCanRemoveExtraMarginWhenBlockRoundIsDisabled( QrCodeOptions $defaultOptions, - ?string $roundBlockSize, + string|null $roundBlockSize, int $expectedColor, ): void { $code = 'abc123'; @@ -234,7 +234,7 @@ class QrCodeActionTest extends TestCase } #[Test, DataProvider('provideColors')] - public function properColorsAreUsed(?string $queryColor, ?string $optionsColor, int $expectedColor): void + public function properColorsAreUsed(string|null $queryColor, string|null $optionsColor, int $expectedColor): void { $code = 'abc123'; $req = ServerRequestFactory::fromGlobals() @@ -320,7 +320,7 @@ class QrCodeActionTest extends TestCase yield 'only enabled short URLs' => [false]; } - public function action(?QrCodeOptions $options = null): QrCodeAction + public function action(QrCodeOptions|null $options = null): QrCodeAction { return new QrCodeAction( $this->urlResolver, diff --git a/module/Core/test/Config/PostProcessor/ShortUrlMethodsProcessorTest.php b/module/Core/test/Config/PostProcessor/ShortUrlMethodsProcessorTest.php index 80d5203a..39e37f32 100644 --- a/module/Core/test/Config/PostProcessor/ShortUrlMethodsProcessorTest.php +++ b/module/Core/test/Config/PostProcessor/ShortUrlMethodsProcessorTest.php @@ -23,14 +23,14 @@ class ShortUrlMethodsProcessorTest extends TestCase #[Test, DataProvider('provideConfigs')] public function onlyFirstRouteIdentifiedAsRedirectIsEditedWithProperAllowedMethods( array $config, - ?array $expectedRoutes, + array|null $expectedRoutes, ): void { self::assertEquals($expectedRoutes, ($this->processor)($config)['routes'] ?? null); } public static function provideConfigs(): iterable { - $buildConfigWithStatus = static fn (int $status, ?array $expectedAllowedMethods) => [[ + $buildConfigWithStatus = static fn (int $status, array|null $expectedAllowedMethods) => [[ 'routes' => [ ['name' => 'foo'], ['name' => 'bar'], diff --git a/module/Core/test/Domain/DomainServiceTest.php b/module/Core/test/Domain/DomainServiceTest.php index 7e2fea18..b7f78c6b 100644 --- a/module/Core/test/Domain/DomainServiceTest.php +++ b/module/Core/test/Domain/DomainServiceTest.php @@ -33,7 +33,7 @@ class DomainServiceTest extends TestCase } #[Test, DataProvider('provideExcludedDomains')] - public function listDomainsDelegatesIntoRepository(array $domains, array $expectedResult, ?ApiKey $apiKey): void + public function listDomainsDelegatesIntoRepository(array $domains, array $expectedResult, ApiKey|null $apiKey): void { $repo = $this->createMock(DomainRepository::class); $repo->expects($this->once())->method('findDomains')->with($apiKey)->willReturn($domains); @@ -124,7 +124,7 @@ class DomainServiceTest extends TestCase } #[Test, DataProvider('provideFoundDomains')] - public function getOrCreateAlwaysPersistsDomain(?Domain $foundDomain, ?ApiKey $apiKey): void + public function getOrCreateAlwaysPersistsDomain(Domain|null $foundDomain, ApiKey|null $apiKey): void { $authority = 'example.com'; $repo = $this->createMock(DomainRepository::class); @@ -161,8 +161,10 @@ class DomainServiceTest extends TestCase } #[Test, DataProvider('provideFoundDomains')] - public function configureNotFoundRedirectsConfiguresFetchedDomain(?Domain $foundDomain, ?ApiKey $apiKey): void - { + public function configureNotFoundRedirectsConfiguresFetchedDomain( + Domain|null $foundDomain, + ApiKey|null $apiKey, + ): void { $authority = 'example.com'; $repo = $this->createMock(DomainRepository::class); $repo->method('findOneByAuthority')->with($authority, $apiKey)->willReturn($foundDomain); diff --git a/module/Core/test/EventDispatcher/LocateVisitTest.php b/module/Core/test/EventDispatcher/LocateVisitTest.php index 63595a6c..80e3e318 100644 --- a/module/Core/test/EventDispatcher/LocateVisitTest.php +++ b/module/Core/test/EventDispatcher/LocateVisitTest.php @@ -155,7 +155,7 @@ class LocateVisitTest extends TestCase } #[Test, DataProvider('provideIpAddresses')] - public function locatableVisitsResolveToLocation(Visit $visit, ?string $originalIpAddress): void + public function locatableVisitsResolveToLocation(Visit $visit, string|null $originalIpAddress): void { $ipAddr = $originalIpAddress ?? $visit->remoteAddr; $location = new Location('', '', '', '', 0.0, 0.0, ''); diff --git a/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php b/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php index 10726273..ed0ada96 100644 --- a/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php +++ b/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php @@ -57,7 +57,7 @@ class SendVisitToMatomoTest extends TestCase } #[Test, DataProvider('provideOriginalIpAddress')] - public function visitIsSentWhenItExists(?string $originalIpAddress): void + public function visitIsSentWhenItExists(string|null $originalIpAddress): void { $visitId = '123'; $visit = Visit::forBasePath(Visitor::emptyInstance()); diff --git a/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php b/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php index 08b4cdb7..7686f4ab 100644 --- a/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php +++ b/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php @@ -41,7 +41,7 @@ class PublishingUpdatesGeneratorTest extends TestCase } #[Test, DataProvider('provideMethod')] - public function visitIsProperlySerializedIntoUpdate(string $method, string $expectedTopic, ?string $title): void + public function visitIsProperlySerializedIntoUpdate(string $method, string $expectedTopic, string|null $title): void { $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([ 'customSlug' => 'foo', diff --git a/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php b/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php index ac744824..1117d5d3 100644 --- a/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php +++ b/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php @@ -179,7 +179,7 @@ class NotifyVisitToRabbitMqTest extends TestCase ]; } - private function listener(?RabbitMqOptions $options = null): NotifyVisitToRabbitMq + private function listener(RabbitMqOptions|null $options = null): NotifyVisitToRabbitMq { return new NotifyVisitToRabbitMq( $this->helper, diff --git a/module/Core/test/EventDispatcher/UpdateGeoLiteDbTest.php b/module/Core/test/EventDispatcher/UpdateGeoLiteDbTest.php index dc604521..3b20ab0c 100644 --- a/module/Core/test/EventDispatcher/UpdateGeoLiteDbTest.php +++ b/module/Core/test/EventDispatcher/UpdateGeoLiteDbTest.php @@ -77,7 +77,7 @@ class UpdateGeoLiteDbTest extends TestCase int $total, int $downloaded, bool $oldDbExists, - ?string $expectedMessage, + string|null $expectedMessage, ): void { $this->dbUpdater->expects($this->once())->method('checkDbUpdate')->withAnyParameters()->willReturnCallback( function ($_, callable $secondCallback) use ($total, $downloaded, $oldDbExists): GeolocationResult { diff --git a/module/Core/test/Exception/NonUniqueSlugExceptionTest.php b/module/Core/test/Exception/NonUniqueSlugExceptionTest.php index c1e0d158..84ec48eb 100644 --- a/module/Core/test/Exception/NonUniqueSlugExceptionTest.php +++ b/module/Core/test/Exception/NonUniqueSlugExceptionTest.php @@ -12,7 +12,7 @@ use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; class NonUniqueSlugExceptionTest extends TestCase { #[Test, DataProvider('provideMessages')] - public function properlyCreatesExceptionFromSlug(string $expectedMessage, string $slug, ?string $domain): void + public function properlyCreatesExceptionFromSlug(string $expectedMessage, string $slug, string|null $domain): void { $expectedAdditional = ['customSlug' => $slug]; if ($domain !== null) { diff --git a/module/Core/test/Exception/ShortUrlNotFoundExceptionTest.php b/module/Core/test/Exception/ShortUrlNotFoundExceptionTest.php index aee8a29f..62e0afa2 100644 --- a/module/Core/test/Exception/ShortUrlNotFoundExceptionTest.php +++ b/module/Core/test/Exception/ShortUrlNotFoundExceptionTest.php @@ -16,7 +16,7 @@ class ShortUrlNotFoundExceptionTest extends TestCase public function properlyCreatesExceptionFromNotFoundShortCode( string $expectedMessage, string $shortCode, - ?string $domain, + string|null $domain, ): void { $expectedAdditional = ['shortCode' => $shortCode]; if ($domain !== null) { diff --git a/module/Core/test/Exception/ValidationExceptionTest.php b/module/Core/test/Exception/ValidationExceptionTest.php index 5bb3baa8..3cb87250 100644 --- a/module/Core/test/Exception/ValidationExceptionTest.php +++ b/module/Core/test/Exception/ValidationExceptionTest.php @@ -20,7 +20,7 @@ use function print_r; class ValidationExceptionTest extends TestCase { #[Test, DataProvider('provideExceptions')] - public function createsExceptionFromInputFilter(?Throwable $prev): void + public function createsExceptionFromInputFilter(Throwable|null $prev): void { $invalidData = [ 'foo' => 'bar', diff --git a/module/Core/test/Importer/ImportedLinksProcessorTest.php b/module/Core/test/Importer/ImportedLinksProcessorTest.php index a1816563..36265aa3 100644 --- a/module/Core/test/Importer/ImportedLinksProcessorTest.php +++ b/module/Core/test/Importer/ImportedLinksProcessorTest.php @@ -127,7 +127,7 @@ class ImportedLinksProcessorTest extends TestCase $this->em->method('getRepository')->with(ShortUrl::class)->willReturn($this->repo); $this->repo->expects($this->exactly(count($urls)))->method('findOneByImportedUrl')->willReturnCallback( - fn (ImportedShlinkUrl $url): ?ShortUrl => contains( + fn (ImportedShlinkUrl $url): ShortUrl|null => contains( $url->longUrl, ['https://foo', 'https://baz2', 'https://baz3'], ) ? ShortUrl::fromImport($url, true) : null, @@ -175,7 +175,7 @@ class ImportedLinksProcessorTest extends TestCase ImportedShlinkUrl $importedUrl, string $expectedOutput, int $amountOfPersistedVisits, - ?ShortUrl $foundShortUrl, + ShortUrl|null $foundShortUrl, ): void { $this->em->method('getRepository')->with(ShortUrl::class)->willReturn($this->repo); $this->repo->expects($this->once())->method('findOneByImportedUrl')->willReturn($foundShortUrl); @@ -232,7 +232,7 @@ class ImportedLinksProcessorTest extends TestCase } #[Test, DataProvider('provideFoundShortUrls')] - public function visitsArePersistedWithProperShortUrl(ShortUrl $originalShortUrl, ?ShortUrl $foundShortUrl): void + public function visitsArePersistedWithProperShortUrl(ShortUrl $originalShortUrl, ShortUrl|null $foundShortUrl): void { $this->em->method('getRepository')->with(ShortUrl::class)->willReturn($this->repo); $this->repo->expects($this->once())->method('findOneByImportedUrl')->willReturn($originalShortUrl); @@ -273,7 +273,7 @@ class ImportedLinksProcessorTest extends TestCase public function properAmountOfOrphanVisitsIsImported( bool $importOrphanVisits, iterable $visits, - ?Visit $lastOrphanVisit, + Visit|null $lastOrphanVisit, int $expectedImportedVisits, ): void { $this->io->expects($this->exactly($importOrphanVisits ? 2 : 1))->method('title'); diff --git a/module/Core/test/Matomo/MatomoTrackerBuilderTest.php b/module/Core/test/Matomo/MatomoTrackerBuilderTest.php index 1b55405e..e0bd0fde 100644 --- a/module/Core/test/Matomo/MatomoTrackerBuilderTest.php +++ b/module/Core/test/Matomo/MatomoTrackerBuilderTest.php @@ -43,7 +43,7 @@ class MatomoTrackerBuilderTest extends TestCase self::assertEquals(MatomoTrackerBuilder::MATOMO_DEFAULT_TIMEOUT, $tracker->getRequestConnectTimeout()); } - private function builder(?MatomoOptions $options = null): MatomoTrackerBuilder + private function builder(MatomoOptions|null $options = null): MatomoTrackerBuilder { $options ??= new MatomoOptions(enabled: true, baseUrl: 'base_url', siteId: 5, apiToken: 'api_token'); return new MatomoTrackerBuilder($options); diff --git a/module/Core/test/Matomo/MatomoVisitSenderTest.php b/module/Core/test/Matomo/MatomoVisitSenderTest.php index 6a4659f1..bf568bfb 100644 --- a/module/Core/test/Matomo/MatomoVisitSenderTest.php +++ b/module/Core/test/Matomo/MatomoVisitSenderTest.php @@ -43,7 +43,7 @@ class MatomoVisitSenderTest extends TestCase } #[Test, DataProvider('provideTrackerMethods')] - public function visitIsSentToMatomo(Visit $visit, ?string $originalIpAddress, array $invokedMethods): void + public function visitIsSentToMatomo(Visit $visit, string|null $originalIpAddress, array $invokedMethods): void { $tracker = $this->createMock(MatomoTracker::class); $tracker->expects($this->once())->method('setUrl')->willReturn($tracker); diff --git a/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php b/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php index 3cd44ef0..b31d1fd3 100644 --- a/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php +++ b/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php @@ -42,7 +42,7 @@ class RedirectConditionTest extends TestCase #[TestWith(['en-UK', 'en', true], 'only lang')] #[TestWith(['es-AR', 'en', false], 'different only lang')] #[TestWith(['fr', 'fr-FR', false], 'less restrictive matching locale')] - public function matchesLanguage(?string $acceptLanguage, string $value, bool $expected): void + public function matchesLanguage(string|null $acceptLanguage, string $value, bool $expected): void { $request = ServerRequestFactory::fromGlobals(); if ($acceptLanguage !== null) { @@ -62,7 +62,7 @@ class RedirectConditionTest extends TestCase #[TestWith([IOS_USER_AGENT, DeviceType::IOS, true])] #[TestWith([IOS_USER_AGENT, DeviceType::ANDROID, false])] #[TestWith([DESKTOP_USER_AGENT, DeviceType::IOS, false])] - public function matchesDevice(?string $userAgent, DeviceType $value, bool $expected): void + public function matchesDevice(string|null $userAgent, DeviceType $value, bool $expected): void { $request = ServerRequestFactory::fromGlobals(); if ($userAgent !== null) { @@ -82,7 +82,7 @@ class RedirectConditionTest extends TestCase #[TestWith(['1.2.3.4', '192.168.1.0/24', false], 'no CIDR block match')] #[TestWith(['192.168.1.35', '192.168.1.*', true], 'wildcard pattern match')] #[TestWith(['1.2.3.4', '192.168.1.*', false], 'no wildcard pattern match')] - public function matchesRemoteIpAddress(?string $remoteIp, string $ipToMatch, bool $expected): void + public function matchesRemoteIpAddress(string|null $remoteIp, string $ipToMatch, bool $expected): void { $request = ServerRequestFactory::fromGlobals(); if ($remoteIp !== null) { diff --git a/module/Core/test/RedirectRule/ShortUrlRedirectionResolverTest.php b/module/Core/test/RedirectRule/ShortUrlRedirectionResolverTest.php index 3bf23863..f26627c6 100644 --- a/module/Core/test/RedirectRule/ShortUrlRedirectionResolverTest.php +++ b/module/Core/test/RedirectRule/ShortUrlRedirectionResolverTest.php @@ -36,7 +36,7 @@ class ShortUrlRedirectionResolverTest extends TestCase #[Test, DataProvider('provideData')] public function resolveLongUrlReturnsExpectedValue( ServerRequestInterface $request, - ?RedirectCondition $condition, + RedirectCondition|null $condition, string $expectedUrl, ): void { $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([ diff --git a/module/Core/test/ShortUrl/Entity/ShortUrlTest.php b/module/Core/test/ShortUrl/Entity/ShortUrlTest.php index b3720254..29e9d88c 100644 --- a/module/Core/test/ShortUrl/Entity/ShortUrlTest.php +++ b/module/Core/test/ShortUrl/Entity/ShortUrlTest.php @@ -75,7 +75,7 @@ class ShortUrlTest extends TestCase } #[Test, DataProvider('provideLengths')] - public function shortCodesHaveExpectedLength(?int $length, int $expectedLength): void + public function shortCodesHaveExpectedLength(int|null $length, int $expectedLength): void { $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData( [ShortUrlInputFilter::SHORT_CODE_LENGTH => $length, 'longUrl' => 'https://longUrl'], @@ -94,7 +94,7 @@ class ShortUrlTest extends TestCase #[TestWith([null, '', 5])] #[TestWith(['foo bar/', 'foo-bar-', 13])] public function shortCodesHaveExpectedPrefix( - ?string $pathPrefix, + string|null $pathPrefix, string $expectedPrefix, int $expectedShortCodeLength, ): void { diff --git a/module/Core/test/ShortUrl/Helper/ShortCodeUniquenessHelperTest.php b/module/Core/test/ShortUrl/Helper/ShortCodeUniquenessHelperTest.php index de1402f6..f341585e 100644 --- a/module/Core/test/ShortUrl/Helper/ShortCodeUniquenessHelperTest.php +++ b/module/Core/test/ShortUrl/Helper/ShortCodeUniquenessHelperTest.php @@ -32,7 +32,7 @@ class ShortCodeUniquenessHelperTest extends TestCase } #[Test, DataProvider('provideDomains')] - public function shortCodeIsRegeneratedIfAlreadyInUse(?Domain $domain, ?string $expectedAuthority): void + public function shortCodeIsRegeneratedIfAlreadyInUse(Domain|null $domain, string|null $expectedAuthority): void { $callIndex = 0; $expectedCalls = 3; diff --git a/module/Core/test/ShortUrl/Helper/ShortUrlRedirectionBuilderTest.php b/module/Core/test/ShortUrl/Helper/ShortUrlRedirectionBuilderTest.php index d1283a78..6f48a836 100644 --- a/module/Core/test/ShortUrl/Helper/ShortUrlRedirectionBuilderTest.php +++ b/module/Core/test/ShortUrl/Helper/ShortUrlRedirectionBuilderTest.php @@ -35,8 +35,8 @@ class ShortUrlRedirectionBuilderTest extends TestCase public function buildShortUrlRedirectBuildsExpectedUrl( string $expectedUrl, ServerRequestInterface $request, - ?string $extraPath, - ?bool $forwardQuery, + string|null $extraPath, + bool|null $forwardQuery, ): void { $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([ 'longUrl' => 'https://example.com/foo/bar?some=thing', diff --git a/module/Core/test/ShortUrl/Helper/ShortUrlStringifierTest.php b/module/Core/test/ShortUrl/Helper/ShortUrlStringifierTest.php index d28fdf0e..03799e10 100644 --- a/module/Core/test/ShortUrl/Helper/ShortUrlStringifierTest.php +++ b/module/Core/test/ShortUrl/Helper/ShortUrlStringifierTest.php @@ -32,7 +32,7 @@ class ShortUrlStringifierTest extends TestCase public static function provideConfigAndShortUrls(): iterable { - $shortUrlWithShortCode = fn (string $shortCode, ?string $domain = null) => ShortUrl::create( + $shortUrlWithShortCode = fn (string $shortCode, string|null $domain = null) => ShortUrl::create( ShortUrlCreation::fromRawData([ 'longUrl' => 'https://longUrl', 'customSlug' => $shortCode, diff --git a/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php b/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php index 6815acb6..85168020 100644 --- a/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php +++ b/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php @@ -70,7 +70,7 @@ class ExtraPathRedirectMiddlewareTest extends TestCase public static function provideNonRedirectingRequests(): iterable { $baseReq = ServerRequestFactory::fromGlobals(); - $buildReq = static fn (?NotFoundType $type): ServerRequestInterface => + $buildReq = static fn (NotFoundType|null $type): ServerRequestInterface => $baseReq->withAttribute(NotFoundType::class, $type); yield 'disabled option' => [false, false, $buildReq(NotFoundType::fromRequest($baseReq, '/foo/bar'))]; @@ -127,7 +127,7 @@ class ExtraPathRedirectMiddlewareTest extends TestCase public function visitIsTrackedAndRedirectIsReturnedWhenShortUrlIsFoundAfterExpectedAmountOfIterations( bool $multiSegmentEnabled, int $expectedResolveCalls, - ?string $expectedExtraPath, + string|null $expectedExtraPath, ): void { $options = new UrlShortenerOptions(appendExtraPath: true, multiSegmentSlugsEnabled: $multiSegmentEnabled); @@ -170,7 +170,7 @@ class ExtraPathRedirectMiddlewareTest extends TestCase yield [true, 3, null]; } - private function middleware(?UrlShortenerOptions $options = null): ExtraPathRedirectMiddleware + private function middleware(UrlShortenerOptions|null $options = null): ExtraPathRedirectMiddleware { return new ExtraPathRedirectMiddleware( $this->resolver, diff --git a/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php b/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php index e963923b..ed9c6459 100644 --- a/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php +++ b/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php @@ -147,7 +147,7 @@ class ShortUrlCreationTest extends TestCase } #[Test, DataProvider('provideTitles')] - public function titleIsCroppedIfTooLong(?string $title, ?string $expectedTitle): void + public function titleIsCroppedIfTooLong(string|null $title, string|null $expectedTitle): void { $creation = ShortUrlCreation::fromRawData([ 'title' => $title, @@ -170,7 +170,7 @@ class ShortUrlCreationTest extends TestCase } #[Test, DataProvider('provideDomains')] - public function emptyDomainIsDiscarded(?string $domain, ?string $expectedDomain): void + public function emptyDomainIsDiscarded(string|null $domain, string|null $expectedDomain): void { $creation = ShortUrlCreation::fromRawData([ 'domain' => $domain, diff --git a/module/Core/test/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php b/module/Core/test/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php index 2ef213d2..473c2320 100644 --- a/module/Core/test/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php +++ b/module/Core/test/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php @@ -28,11 +28,11 @@ class ShortUrlRepositoryAdapterTest extends TestCase #[Test, DataProvider('provideFilteringArgs')] public function getItemsFallsBackToFindList( - ?string $searchTerm = null, + string|null $searchTerm = null, array $tags = [], - ?string $startDate = null, - ?string $endDate = null, - ?string $orderBy = null, + string|null $startDate = null, + string|null $endDate = null, + string|null $orderBy = null, ): void { $params = ShortUrlsParams::fromRawData([ 'searchTerm' => $searchTerm, @@ -54,10 +54,10 @@ class ShortUrlRepositoryAdapterTest extends TestCase #[Test, DataProvider('provideFilteringArgs')] public function countFallsBackToCountList( - ?string $searchTerm = null, + string|null $searchTerm = null, array $tags = [], - ?string $startDate = null, - ?string $endDate = null, + string|null $startDate = null, + string|null $endDate = null, ): void { $params = ShortUrlsParams::fromRawData([ 'searchTerm' => $searchTerm, diff --git a/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php b/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php index 722ac347..934d8511 100644 --- a/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php +++ b/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php @@ -33,7 +33,7 @@ class PersistenceShortUrlRelationResolverTest extends TestCase } #[Test, DataProvider('provideDomainsThatEmpty')] - public function returnsEmptyInSomeCases(?string $domain): void + public function returnsEmptyInSomeCases(string|null $domain): void { $this->em->expects($this->never())->method('getRepository')->with(Domain::class); self::assertNull($this->resolver->resolveDomain($domain)); @@ -46,7 +46,7 @@ class PersistenceShortUrlRelationResolverTest extends TestCase } #[Test, DataProvider('provideFoundDomains')] - public function findsOrCreatesDomainWhenValueIsProvided(?Domain $foundDomain, string $authority): void + public function findsOrCreatesDomainWhenValueIsProvided(Domain|null $foundDomain, string $authority): void { $repo = $this->createMock(DomainRepository::class); $repo->expects($this->once())->method('findOneBy')->with(['authority' => $authority])->willReturn($foundDomain); @@ -79,7 +79,7 @@ class PersistenceShortUrlRelationResolverTest extends TestCase $tagRepo = $this->createMock(TagRepository::class); $tagRepo->expects($this->exactly($expectedLookedOutTags))->method('findOneBy')->with( $this->isType('array'), - )->willReturnCallback(function (array $criteria): ?Tag { + )->willReturnCallback(function (array $criteria): Tag|null { ['name' => $name] = $criteria; return $name === 'foo' ? new Tag($name) : null; }); diff --git a/module/Core/test/ShortUrl/Resolver/SimpleShortUrlRelationResolverTest.php b/module/Core/test/ShortUrl/Resolver/SimpleShortUrlRelationResolverTest.php index f74480ba..95e95785 100644 --- a/module/Core/test/ShortUrl/Resolver/SimpleShortUrlRelationResolverTest.php +++ b/module/Core/test/ShortUrl/Resolver/SimpleShortUrlRelationResolverTest.php @@ -21,7 +21,7 @@ class SimpleShortUrlRelationResolverTest extends TestCase } #[Test, DataProvider('provideDomains')] - public function resolvesExpectedDomain(?string $domain): void + public function resolvesExpectedDomain(string|null $domain): void { $result = $this->resolver->resolveDomain($domain); diff --git a/module/Core/test/ShortUrl/ShortUrlListServiceTest.php b/module/Core/test/ShortUrl/ShortUrlListServiceTest.php index 2ae5c584..c22bc206 100644 --- a/module/Core/test/ShortUrl/ShortUrlListServiceTest.php +++ b/module/Core/test/ShortUrl/ShortUrlListServiceTest.php @@ -30,7 +30,7 @@ class ShortUrlListServiceTest extends TestCase } #[Test, DataProviderExternal(ApiKeyDataProviders::class, 'adminApiKeysProvider')] - public function listedUrlsAreReturnedFromEntityManager(?ApiKey $apiKey): void + public function listedUrlsAreReturnedFromEntityManager(ApiKey|null $apiKey): void { $list = [ ShortUrl::createFake(), diff --git a/module/Core/test/ShortUrl/ShortUrlResolverTest.php b/module/Core/test/ShortUrl/ShortUrlResolverTest.php index e8443a13..24571b41 100644 --- a/module/Core/test/ShortUrl/ShortUrlResolverTest.php +++ b/module/Core/test/ShortUrl/ShortUrlResolverTest.php @@ -42,7 +42,7 @@ class ShortUrlResolverTest extends TestCase } #[Test, DataProviderExternal(ApiKeyDataProviders::class, 'adminApiKeysProvider')] - public function shortCodeIsProperlyParsed(?ApiKey $apiKey): void + public function shortCodeIsProperlyParsed(ApiKey|null $apiKey): void { $shortUrl = ShortUrl::withLongUrl('https://expected_url'); $shortCode = $shortUrl->getShortCode(); @@ -59,7 +59,7 @@ class ShortUrlResolverTest extends TestCase } #[Test, DataProviderExternal(ApiKeyDataProviders::class, 'adminApiKeysProvider')] - public function exceptionIsThrownIfShortCodeIsNotFound(?ApiKey $apiKey): void + public function exceptionIsThrownIfShortCodeIsNotFound(ApiKey|null $apiKey): void { $shortCode = 'abc123'; $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode); diff --git a/module/Core/test/ShortUrl/ShortUrlServiceTest.php b/module/Core/test/ShortUrl/ShortUrlServiceTest.php index 669015a0..c3554363 100644 --- a/module/Core/test/ShortUrl/ShortUrlServiceTest.php +++ b/module/Core/test/ShortUrl/ShortUrlServiceTest.php @@ -48,7 +48,7 @@ class ShortUrlServiceTest extends TestCase public function updateShortUrlUpdatesProvidedData( InvocationOrder $expectedValidateCalls, ShortUrlEdition $shortUrlEdit, - ?ApiKey $apiKey, + ApiKey|null $apiKey, ): void { $originalLongUrl = 'https://originalLongUrl'; $shortUrl = ShortUrl::withLongUrl($originalLongUrl); diff --git a/module/Core/test/Tag/TagServiceTest.php b/module/Core/test/Tag/TagServiceTest.php index f22a35f2..7e82eb1c 100644 --- a/module/Core/test/Tag/TagServiceTest.php +++ b/module/Core/test/Tag/TagServiceTest.php @@ -55,7 +55,7 @@ class TagServiceTest extends TestCase #[Test, DataProvider('provideApiKeysAndSearchTerm')] public function tagsInfoDelegatesOnRepository( - ?ApiKey $apiKey, + ApiKey|null $apiKey, TagsParams $params, TagsListFiltering $expectedFiltering, int $countCalls, @@ -101,7 +101,7 @@ class TagServiceTest extends TestCase } #[Test, DataProviderExternal(ApiKeyDataProviders::class, 'adminApiKeysProvider')] - public function deleteTagsDelegatesOnRepository(?ApiKey $apiKey): void + public function deleteTagsDelegatesOnRepository(ApiKey|null $apiKey): void { $this->repo->expects($this->once())->method('deleteByName')->with(['foo', 'bar'])->willReturn(4); $this->service->deleteTags(['foo', 'bar'], $apiKey); @@ -122,7 +122,7 @@ class TagServiceTest extends TestCase } #[Test, DataProviderExternal(ApiKeyDataProviders::class, 'adminApiKeysProvider')] - public function renameInvalidTagThrowsException(?ApiKey $apiKey): void + public function renameInvalidTagThrowsException(ApiKey|null $apiKey): void { $this->repo->expects($this->once())->method('findOneBy')->willReturn(null); $this->expectException(TagNotFoundException::class); @@ -152,7 +152,7 @@ class TagServiceTest extends TestCase } #[Test, DataProviderExternal(ApiKeyDataProviders::class, 'adminApiKeysProvider')] - public function renameTagToAnExistingNameThrowsException(?ApiKey $apiKey): void + public function renameTagToAnExistingNameThrowsException(ApiKey|null $apiKey): void { $this->repo->expects($this->once())->method('findOneBy')->willReturn(new Tag('foo')); $this->repo->expects($this->once())->method('count')->willReturn(1); diff --git a/module/Core/test/Util/RedirectResponseHelperTest.php b/module/Core/test/Util/RedirectResponseHelperTest.php index 89d3fa5a..b01333d5 100644 --- a/module/Core/test/Util/RedirectResponseHelperTest.php +++ b/module/Core/test/Util/RedirectResponseHelperTest.php @@ -18,7 +18,7 @@ class RedirectResponseHelperTest extends TestCase int $configuredStatus, int $configuredLifetime, int $expectedStatus, - ?string $expectedCacheControl, + string|null $expectedCacheControl, ): void { $options = new RedirectOptions($configuredStatus, $configuredLifetime); @@ -46,7 +46,7 @@ class RedirectResponseHelperTest extends TestCase yield 'status 308 with negative expiration' => [308, -20, 308, 'private,max-age=30']; } - private function helper(?RedirectOptions $options = null): RedirectResponseHelper + private function helper(RedirectOptions|null $options = null): RedirectResponseHelper { return new RedirectResponseHelper($options ?? new RedirectOptions()); } diff --git a/module/Core/test/Visit/Entity/VisitTest.php b/module/Core/test/Visit/Entity/VisitTest.php index 923b2e6b..3556c1f1 100644 --- a/module/Core/test/Visit/Entity/VisitTest.php +++ b/module/Core/test/Visit/Entity/VisitTest.php @@ -103,8 +103,11 @@ class VisitTest extends TestCase } #[Test, DataProvider('provideAddresses')] - public function addressIsAnonymizedWhenRequested(bool $anonymize, ?string $address, ?string $expectedAddress): void - { + public function addressIsAnonymizedWhenRequested( + bool $anonymize, + string|null $address, + string|null $expectedAddress, + ): void { $visit = Visit::forValidShortUrl( ShortUrl::createFake(), new Visitor('Chrome', 'some site', $address, ''), diff --git a/module/Core/test/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapterTest.php b/module/Core/test/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapterTest.php index c96e5be5..d1f7c89b 100644 --- a/module/Core/test/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapterTest.php +++ b/module/Core/test/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapterTest.php @@ -58,7 +58,7 @@ class ShortUrlVisitsPaginatorAdapterTest extends TestCase } } - private function createAdapter(?ApiKey $apiKey): ShortUrlVisitsPaginatorAdapter + private function createAdapter(ApiKey|null $apiKey): ShortUrlVisitsPaginatorAdapter { return new ShortUrlVisitsPaginatorAdapter( $this->repo, diff --git a/module/Core/test/Visit/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php b/module/Core/test/Visit/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php index 59ce2082..c0cd4d0b 100644 --- a/module/Core/test/Visit/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php +++ b/module/Core/test/Visit/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php @@ -57,7 +57,7 @@ class VisitsForTagPaginatorAdapterTest extends TestCase } } - private function createAdapter(?ApiKey $apiKey): TagVisitsPaginatorAdapter + private function createAdapter(ApiKey|null $apiKey): TagVisitsPaginatorAdapter { return new TagVisitsPaginatorAdapter($this->repo, 'foo', VisitsParams::fromRawData([]), $apiKey); } diff --git a/module/Core/test/Visit/VisitsStatsHelperTest.php b/module/Core/test/Visit/VisitsStatsHelperTest.php index 61fb1293..10c11b64 100644 --- a/module/Core/test/Visit/VisitsStatsHelperTest.php +++ b/module/Core/test/Visit/VisitsStatsHelperTest.php @@ -56,7 +56,7 @@ class VisitsStatsHelperTest extends TestCase } #[Test, DataProvider('provideCounts')] - public function returnsExpectedVisitsStats(int $expectedCount, ?ApiKey $apiKey): void + public function returnsExpectedVisitsStats(int $expectedCount, ApiKey|null $apiKey): void { $callCount = 0; $visitsCountRepo = $this->createMock(ShortUrlVisitsCountRepository::class); @@ -94,7 +94,7 @@ class VisitsStatsHelperTest extends TestCase } #[Test, DataProviderExternal(ApiKeyDataProviders::class, 'adminApiKeysProvider')] - public function infoReturnsVisitsForCertainShortCode(?ApiKey $apiKey): void + public function infoReturnsVisitsForCertainShortCode(ApiKey|null $apiKey): void { $shortCode = '123ABC'; $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode); @@ -157,7 +157,7 @@ class VisitsStatsHelperTest extends TestCase } #[Test, DataProviderExternal(ApiKeyDataProviders::class, 'adminApiKeysProvider')] - public function visitsForTagAreReturnedAsExpected(?ApiKey $apiKey): void + public function visitsForTagAreReturnedAsExpected(ApiKey|null $apiKey): void { $tag = 'foo'; $repo = $this->createMock(TagRepository::class); @@ -198,7 +198,7 @@ class VisitsStatsHelperTest extends TestCase } #[Test, DataProviderExternal(ApiKeyDataProviders::class, 'adminApiKeysProvider')] - public function visitsForNonDefaultDomainAreReturnedAsExpected(?ApiKey $apiKey): void + public function visitsForNonDefaultDomainAreReturnedAsExpected(ApiKey|null $apiKey): void { $domain = 'foo.com'; $repo = $this->createMock(DomainRepository::class); @@ -229,7 +229,7 @@ class VisitsStatsHelperTest extends TestCase } #[Test, DataProviderExternal(ApiKeyDataProviders::class, 'adminApiKeysProvider')] - public function visitsForDefaultDomainAreReturnedAsExpected(?ApiKey $apiKey): void + public function visitsForDefaultDomainAreReturnedAsExpected(ApiKey|null $apiKey): void { $repo = $this->createMock(DomainRepository::class); $repo->expects($this->never())->method('domainExists'); diff --git a/module/Core/test/Visit/VisitsTrackerTest.php b/module/Core/test/Visit/VisitsTrackerTest.php index 414f5254..f45a27d8 100644 --- a/module/Core/test/Visit/VisitsTrackerTest.php +++ b/module/Core/test/Visit/VisitsTrackerTest.php @@ -79,7 +79,7 @@ class VisitsTrackerTest extends TestCase yield 'trackRegularNotFoundVisit' => ['trackRegularNotFoundVisit']; } - private function visitsTracker(?TrackingOptions $options = null): VisitsTracker + private function visitsTracker(TrackingOptions|null $options = null): VisitsTracker { return new VisitsTracker($this->em, $this->eventDispatcher, $options ?? new TrackingOptions()); } diff --git a/module/Rest/src/Action/Domain/Request/DomainRedirectsRequest.php b/module/Rest/src/Action/Domain/Request/DomainRedirectsRequest.php index e2b27e23..0c55f967 100644 --- a/module/Rest/src/Action/Domain/Request/DomainRedirectsRequest.php +++ b/module/Rest/src/Action/Domain/Request/DomainRedirectsRequest.php @@ -14,11 +14,11 @@ use function array_key_exists; class DomainRedirectsRequest { private string $authority; - private ?string $baseUrlRedirect = null; + private string|null $baseUrlRedirect = null; private bool $baseUrlRedirectWasProvided = false; - private ?string $regular404Redirect = null; + private string|null $regular404Redirect = null; private bool $regular404RedirectWasProvided = false; - private ?string $invalidShortUrlRedirect = null; + private string|null $invalidShortUrlRedirect = null; private bool $invalidShortUrlRedirectWasProvided = false; private function __construct() @@ -66,7 +66,7 @@ class DomainRedirectsRequest return $this->authority; } - public function toNotFoundRedirects(?NotFoundRedirectConfigInterface $defaults = null): NotFoundRedirects + public function toNotFoundRedirects(NotFoundRedirectConfigInterface|null $defaults = null): NotFoundRedirects { return NotFoundRedirects::withRedirects( $this->baseUrlRedirectWasProvided ? $this->baseUrlRedirect : $defaults?->baseUrlRedirect(), diff --git a/module/Rest/src/ApiKey/Model/ApiKeyMeta.php b/module/Rest/src/ApiKey/Model/ApiKeyMeta.php index e28a9ec3..020c1f27 100644 --- a/module/Rest/src/ApiKey/Model/ApiKeyMeta.php +++ b/module/Rest/src/ApiKey/Model/ApiKeyMeta.php @@ -14,8 +14,8 @@ final class ApiKeyMeta */ private function __construct( public readonly string $key, - public readonly ?string $name, - public readonly ?Chronos $expirationDate, + public readonly string|null $name, + public readonly Chronos|null $expirationDate, public readonly iterable $roleDefinitions, ) { } @@ -29,9 +29,9 @@ final class ApiKeyMeta * @param iterable $roleDefinitions */ public static function fromParams( - ?string $key = null, - ?string $name = null, - ?Chronos $expirationDate = null, + string|null $key = null, + string|null $name = null, + Chronos|null $expirationDate = null, iterable $roleDefinitions = [], ): self { return new self( diff --git a/module/Rest/src/ApiKey/Repository/ApiKeyRepository.php b/module/Rest/src/ApiKey/Repository/ApiKeyRepository.php index 2b7aa0a2..2d82b23e 100644 --- a/module/Rest/src/ApiKey/Repository/ApiKeyRepository.php +++ b/module/Rest/src/ApiKey/Repository/ApiKeyRepository.php @@ -17,10 +17,10 @@ class ApiKeyRepository extends EntitySpecificationRepository implements ApiKeyRe /** * Will create provided API key with admin permissions, only if there's no other API keys yet */ - public function createInitialApiKey(string $apiKey): ?ApiKey + public function createInitialApiKey(string $apiKey): ApiKey|null { $em = $this->getEntityManager(); - return $em->wrapInTransaction(function () use ($apiKey, $em): ?ApiKey { + return $em->wrapInTransaction(function () use ($apiKey, $em): ApiKey|null { // Ideally this would be a SELECT COUNT(...), but MsSQL and Postgres do not allow locking on aggregates // Because of that we check if at least one result exists $firstResult = $em->createQueryBuilder()->select('a.id') diff --git a/module/Rest/src/ApiKey/Repository/ApiKeyRepositoryInterface.php b/module/Rest/src/ApiKey/Repository/ApiKeyRepositoryInterface.php index 57c2a7f6..04e55519 100644 --- a/module/Rest/src/ApiKey/Repository/ApiKeyRepositoryInterface.php +++ b/module/Rest/src/ApiKey/Repository/ApiKeyRepositoryInterface.php @@ -16,5 +16,5 @@ interface ApiKeyRepositoryInterface extends ObjectRepository, EntitySpecificatio /** * Will create provided API key only if there's no API keys yet */ - public function createInitialApiKey(string $apiKey): ?ApiKey; + public function createInitialApiKey(string $apiKey): ApiKey|null; } diff --git a/module/Rest/src/ApiKey/Role.php b/module/Rest/src/ApiKey/Role.php index 4f3685db..7cca292d 100644 --- a/module/Rest/src/ApiKey/Role.php +++ b/module/Rest/src/ApiKey/Role.php @@ -38,7 +38,7 @@ enum Role: string }; } - public static function toSpec(ApiKeyRole $role, ?string $context = null): Specification + public static function toSpec(ApiKeyRole $role, string|null $context = null): Specification { return match ($role->role) { self::AUTHORED_SHORT_URLS => new BelongsToApiKey($role->apiKey, $context), diff --git a/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php b/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php index 122829ed..d7c2e7ba 100644 --- a/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php +++ b/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php @@ -11,8 +11,10 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class WithApiKeySpecsEnsuringJoin extends BaseSpecification { - public function __construct(private readonly ?ApiKey $apiKey, private readonly string $fieldToJoin = 'shortUrls') - { + public function __construct( + private readonly ApiKey|null $apiKey, + private readonly string $fieldToJoin = 'shortUrls', + ) { parent::__construct(); } diff --git a/module/Rest/src/ConfigProvider.php b/module/Rest/src/ConfigProvider.php index 067c6952..1768c7c8 100644 --- a/module/Rest/src/ConfigProvider.php +++ b/module/Rest/src/ConfigProvider.php @@ -33,7 +33,7 @@ class ConfigProvider return $healthRoute !== null ? [...$prefixedRoutes, $healthRoute] : $prefixedRoutes; } - private static function buildUnversionedHealthRouteFromExistingRoutes(array $routes): ?array + private static function buildUnversionedHealthRouteFromExistingRoutes(array $routes): array|null { $healthRoutes = array_filter($routes, fn (array $route) => $route['path'] === '/health'); $healthRoute = reset($healthRoutes); diff --git a/module/Rest/src/Entity/ApiKey.php b/module/Rest/src/Entity/ApiKey.php index 46548dcf..1cca4f3a 100644 --- a/module/Rest/src/Entity/ApiKey.php +++ b/module/Rest/src/Entity/ApiKey.php @@ -23,8 +23,8 @@ class ApiKey extends AbstractEntity */ private function __construct( private string $key, - public readonly ?string $name = null, - public readonly ?Chronos $expirationDate = null, + public readonly string|null $name = null, + public readonly Chronos|null $expirationDate = null, private bool $enabled = true, private Collection $roles = new ArrayCollection(), ) { @@ -85,7 +85,7 @@ class ApiKey extends AbstractEntity return $this->key; } - public function spec(?string $context = null): Specification + public function spec(string|null $context = null): Specification { $specs = $this->roles->map(fn (ApiKeyRole $role) => Role::toSpec($role, $context))->getValues(); return Spec::andX(...$specs); @@ -100,7 +100,7 @@ class ApiKey extends AbstractEntity /** * @return ($apiKey is null ? true : boolean) */ - public static function isAdmin(?ApiKey $apiKey): bool + public static function isAdmin(ApiKey|null $apiKey): bool { return $apiKey === null || $apiKey->roles->isEmpty(); } @@ -108,7 +108,7 @@ class ApiKey extends AbstractEntity /** * Tells if provided API key has any of the roles restricting at the short URL level */ - public static function isShortUrlRestricted(?ApiKey $apiKey): bool + public static function isShortUrlRestricted(ApiKey|null $apiKey): bool { if ($apiKey === null) { return false; diff --git a/module/Rest/src/Service/ApiKeyCheckResult.php b/module/Rest/src/Service/ApiKeyCheckResult.php index ff74fb79..4a1fc1cf 100644 --- a/module/Rest/src/Service/ApiKeyCheckResult.php +++ b/module/Rest/src/Service/ApiKeyCheckResult.php @@ -8,7 +8,7 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; final class ApiKeyCheckResult { - public function __construct(public readonly ?ApiKey $apiKey = null) + public function __construct(public readonly ApiKey|null $apiKey = null) { } diff --git a/module/Rest/src/Service/ApiKeyService.php b/module/Rest/src/Service/ApiKeyService.php index 21f69f90..6c825a4a 100644 --- a/module/Rest/src/Service/ApiKeyService.php +++ b/module/Rest/src/Service/ApiKeyService.php @@ -28,7 +28,7 @@ class ApiKeyService implements ApiKeyServiceInterface return $apiKey; } - public function createInitial(string $key): ?ApiKey + public function createInitial(string $key): ApiKey|null { /** @var ApiKeyRepositoryInterface $repo */ $repo = $this->em->getRepository(ApiKey::class); @@ -67,7 +67,7 @@ class ApiKeyService implements ApiKeyServiceInterface return $apiKeys; } - private function getByKey(string $key): ?ApiKey + private function getByKey(string $key): ApiKey|null { /** @var ApiKey|null $apiKey */ $apiKey = $this->em->getRepository(ApiKey::class)->findOneBy([ diff --git a/module/Rest/src/Service/ApiKeyServiceInterface.php b/module/Rest/src/Service/ApiKeyServiceInterface.php index b82d7760..167041c5 100644 --- a/module/Rest/src/Service/ApiKeyServiceInterface.php +++ b/module/Rest/src/Service/ApiKeyServiceInterface.php @@ -12,7 +12,7 @@ interface ApiKeyServiceInterface { public function create(ApiKeyMeta $apiKeyMeta): ApiKey; - public function createInitial(string $key): ?ApiKey; + public function createInitial(string $key): ApiKey|null; public function check(string $key): ApiKeyCheckResult; diff --git a/module/Rest/test-api/Action/CreateShortUrlTest.php b/module/Rest/test-api/Action/CreateShortUrlTest.php index 42742bbb..212b545c 100644 --- a/module/Rest/test-api/Action/CreateShortUrlTest.php +++ b/module/Rest/test-api/Action/CreateShortUrlTest.php @@ -39,7 +39,7 @@ class CreateShortUrlTest extends ApiTestCase } #[Test, DataProvider('provideConflictingSlugs')] - public function failsToCreateShortUrlWithDuplicatedSlug(string $slug, ?string $domain): void + public function failsToCreateShortUrlWithDuplicatedSlug(string $slug, string|null $domain): void { $suffix = $domain === null ? '' : sprintf(' for domain "%s"', $domain); $detail = sprintf('Provided slug "%s" is already in use%s.', $slug, $suffix); @@ -171,8 +171,10 @@ class CreateShortUrlTest extends ApiTestCase } #[Test, DataProvider('provideConflictingSlugs')] - public function returnsErrorWhenRequestingReturnExistingButCustomSlugIsInUse(string $slug, ?string $domain): void - { + public function returnsErrorWhenRequestingReturnExistingButCustomSlugIsInUse( + string $slug, + string|null $domain, + ): void { $longUrl = 'https://www.alejandrocelaya.com'; [$firstStatusCode] = $this->createShortUrl(['longUrl' => $longUrl]); @@ -269,7 +271,7 @@ class CreateShortUrlTest extends ApiTestCase } #[Test, DataProvider('provideDomains')] - public function apiKeyDomainIsEnforced(?string $providedDomain): void + public function apiKeyDomainIsEnforced(string|null $providedDomain): void { [$statusCode, ['domain' => $returnedDomain]] = $this->createShortUrl( ['domain' => $providedDomain], @@ -315,7 +317,7 @@ class CreateShortUrlTest extends ApiTestCase #[Test] #[TestWith([null])] #[TestWith(['my-custom-slug'])] - public function prefixCanBeSet(?string $customSlug): void + public function prefixCanBeSet(string|null $customSlug): void { [$statusCode, $payload] = $this->createShortUrl([ 'longUrl' => 'https://github.com/shlinkio/shlink/issues/1557', diff --git a/module/Rest/test-api/Action/DeleteShortUrlTest.php b/module/Rest/test-api/Action/DeleteShortUrlTest.php index 06848c48..90df09c4 100644 --- a/module/Rest/test-api/Action/DeleteShortUrlTest.php +++ b/module/Rest/test-api/Action/DeleteShortUrlTest.php @@ -18,7 +18,7 @@ class DeleteShortUrlTest extends ApiTestCase #[Test, DataProviderExternal(ApiTestDataProviders::class, 'invalidUrlsProvider')] public function notFoundErrorIsReturnWhenDeletingInvalidUrl( string $shortCode, - ?string $domain, + string|null $domain, string $expectedDetail, string $apiKey, ): void { diff --git a/module/Rest/test-api/Action/EditShortUrlTest.php b/module/Rest/test-api/Action/EditShortUrlTest.php index 24f91e58..2146a95c 100644 --- a/module/Rest/test-api/Action/EditShortUrlTest.php +++ b/module/Rest/test-api/Action/EditShortUrlTest.php @@ -90,7 +90,7 @@ class EditShortUrlTest extends ApiTestCase #[Test, DataProviderExternal(ApiTestDataProviders::class, 'invalidUrlsProvider')] public function tryingToEditInvalidUrlReturnsNotFoundError( string $shortCode, - ?string $domain, + string|null $domain, string $expectedDetail, string $apiKey, ): void { @@ -125,7 +125,7 @@ class EditShortUrlTest extends ApiTestCase } #[Test, DataProvider('provideDomains')] - public function metadataIsEditedOnProperShortUrlBasedOnDomain(?string $domain, string $expectedUrl): void + public function metadataIsEditedOnProperShortUrlBasedOnDomain(string|null $domain, string $expectedUrl): void { $shortCode = 'ghi789'; $url = new Uri(sprintf('/short-urls/%s', $shortCode)); diff --git a/module/Rest/test-api/Action/ResolveShortUrlTest.php b/module/Rest/test-api/Action/ResolveShortUrlTest.php index 0c0ce5ec..e02c9247 100644 --- a/module/Rest/test-api/Action/ResolveShortUrlTest.php +++ b/module/Rest/test-api/Action/ResolveShortUrlTest.php @@ -45,7 +45,7 @@ class ResolveShortUrlTest extends ApiTestCase #[Test, DataProviderExternal(ApiTestDataProviders::class, 'invalidUrlsProvider')] public function tryingToResolveInvalidUrlReturnsNotFoundError( string $shortCode, - ?string $domain, + string|null $domain, string $expectedDetail, string $apiKey, ): void { diff --git a/module/Rest/test-api/Action/ShortUrlVisitsTest.php b/module/Rest/test-api/Action/ShortUrlVisitsTest.php index 8db002c4..658fe88a 100644 --- a/module/Rest/test-api/Action/ShortUrlVisitsTest.php +++ b/module/Rest/test-api/Action/ShortUrlVisitsTest.php @@ -21,7 +21,7 @@ class ShortUrlVisitsTest extends ApiTestCase #[Test, DataProviderExternal(ApiTestDataProviders::class, 'invalidUrlsProvider')] public function tryingToGetVisitsForInvalidUrlReturnsNotFoundError( string $shortCode, - ?string $domain, + string|null $domain, string $expectedDetail, string $apiKey, ): void { @@ -42,7 +42,7 @@ class ShortUrlVisitsTest extends ApiTestCase } #[Test, DataProvider('provideDomains')] - public function properVisitsAreReturnedWhenDomainIsProvided(?string $domain, int $expectedAmountOfVisits): void + public function properVisitsAreReturnedWhenDomainIsProvided(string|null $domain, int $expectedAmountOfVisits): void { $shortCode = 'ghi789'; $url = new Uri(sprintf('/short-urls/%s/visits', $shortCode)); diff --git a/module/Rest/test-api/Action/SingleStepCreateShortUrlTest.php b/module/Rest/test-api/Action/SingleStepCreateShortUrlTest.php index 038e3f38..974ac0a5 100644 --- a/module/Rest/test-api/Action/SingleStepCreateShortUrlTest.php +++ b/module/Rest/test-api/Action/SingleStepCreateShortUrlTest.php @@ -13,7 +13,7 @@ use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; class SingleStepCreateShortUrlTest extends ApiTestCase { #[Test, DataProvider('provideFormats')] - public function createsNewShortUrlWithExpectedResponse(?string $format, string $expectedContentType): void + public function createsNewShortUrlWithExpectedResponse(string|null $format, string $expectedContentType): void { $resp = $this->createShortUrl($format, 'valid_api_key'); @@ -43,7 +43,7 @@ class SingleStepCreateShortUrlTest extends ApiTestCase self::assertEquals('Invalid authorization', $payload['title']); } - private function createShortUrl(?string $format = 'json', ?string $apiKey = null): ResponseInterface + private function createShortUrl(string|null $format = 'json', string|null $apiKey = null): ResponseInterface { $query = [ 'longUrl' => 'https://app.shlink.io', diff --git a/module/Rest/test-api/Fixtures/ApiKeyFixture.php b/module/Rest/test-api/Fixtures/ApiKeyFixture.php index 949a80c3..1b4f64ea 100644 --- a/module/Rest/test-api/Fixtures/ApiKeyFixture.php +++ b/module/Rest/test-api/Fixtures/ApiKeyFixture.php @@ -49,7 +49,7 @@ class ApiKeyFixture extends AbstractFixture implements DependentFixtureInterface $manager->flush(); } - private function buildApiKey(string $key, bool $enabled, ?Chronos $expiresAt = null): ApiKey + private function buildApiKey(string $key, bool $enabled, Chronos|null $expiresAt = null): ApiKey { $apiKey = ApiKey::fromMeta(ApiKeyMeta::fromParams(expirationDate: $expiresAt)); $ref = new ReflectionObject($apiKey); diff --git a/module/Rest/test-api/Utils/UrlBuilder.php b/module/Rest/test-api/Utils/UrlBuilder.php index 6de96a81..e61d6ad4 100644 --- a/module/Rest/test-api/Utils/UrlBuilder.php +++ b/module/Rest/test-api/Utils/UrlBuilder.php @@ -11,7 +11,7 @@ use function sprintf; class UrlBuilder { - public static function buildShortUrlPath(string $shortCode, ?string $domain, string $suffix = ''): string + public static function buildShortUrlPath(string $shortCode, string|null $domain, string $suffix = ''): string { $url = new Uri(sprintf('/short-urls/%s%s', $shortCode, $suffix)); if ($domain !== null) { diff --git a/module/Rest/test/Action/Domain/Request/DomainRedirectsRequestTest.php b/module/Rest/test/Action/Domain/Request/DomainRedirectsRequestTest.php index 292c1748..45faf9f2 100644 --- a/module/Rest/test/Action/Domain/Request/DomainRedirectsRequestTest.php +++ b/module/Rest/test/Action/Domain/Request/DomainRedirectsRequestTest.php @@ -30,11 +30,11 @@ class DomainRedirectsRequestTest extends TestCase #[Test, DataProvider('provideValidData')] public function isProperlyCastToNotFoundRedirects( array $data, - ?NotFoundRedirectConfigInterface $defaults, + NotFoundRedirectConfigInterface|null $defaults, string $expectedAuthority, - ?string $expectedBaseUrlRedirect, - ?string $expectedRegular404Redirect, - ?string $expectedInvalidShortUrlRedirect, + string|null $expectedBaseUrlRedirect, + string|null $expectedRegular404Redirect, + string|null $expectedInvalidShortUrlRedirect, ): void { $request = DomainRedirectsRequest::fromRawData($data); $notFound = $request->toNotFoundRedirects($defaults); diff --git a/module/Rest/test/Action/MercureInfoActionTest.php b/module/Rest/test/Action/MercureInfoActionTest.php index ce4ad04f..69bfb56a 100644 --- a/module/Rest/test/Action/MercureInfoActionTest.php +++ b/module/Rest/test/Action/MercureInfoActionTest.php @@ -49,7 +49,7 @@ class MercureInfoActionTest extends TestCase } #[Test, DataProvider('provideDays')] - public function returnsExpectedInfoWhenEverythingIsOk(?int $days): void + public function returnsExpectedInfoWhenEverythingIsOk(int|null $days): void { $this->provider->expects($this->once())->method('buildSubscriptionToken')->willReturn('abc.123'); diff --git a/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php b/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php index a3beba1b..ae99f0a9 100644 --- a/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php +++ b/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php @@ -39,11 +39,11 @@ class ListShortUrlsActionTest extends TestCase public function properListReturnsSuccessResponse( array $query, int $expectedPage, - ?string $expectedSearchTerm, + string|null $expectedSearchTerm, array $expectedTags, - ?string $expectedOrderBy, - ?string $startDate = null, - ?string $endDate = null, + string|null $expectedOrderBy, + string|null $startDate = null, + string|null $endDate = null, ): void { $apiKey = ApiKey::create(); $request = ServerRequestFactory::fromGlobals()->withQueryParams($query) diff --git a/module/Rest/test/Action/Tag/DeleteTagsActionTest.php b/module/Rest/test/Action/Tag/DeleteTagsActionTest.php index 5d036097..7545cd6a 100644 --- a/module/Rest/test/Action/Tag/DeleteTagsActionTest.php +++ b/module/Rest/test/Action/Tag/DeleteTagsActionTest.php @@ -25,7 +25,7 @@ class DeleteTagsActionTest extends TestCase } #[Test, DataProvider('provideTags')] - public function processDelegatesIntoService(?array $tags): void + public function processDelegatesIntoService(array|null $tags): void { $request = (new ServerRequest()) ->withQueryParams(['tags' => $tags]) diff --git a/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php b/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php index 100b146e..b74a435f 100644 --- a/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php +++ b/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php @@ -82,7 +82,7 @@ class CrossDomainMiddlewareTest extends TestCase #[Test, DataProvider('provideRouteResults')] public function optionsRequestParsesRouteMatchToDetermineAllowedMethods( - ?string $allowHeader, + string|null $allowHeader, string $expectedAllowedMethods, ): void { $originalResponse = new Response(); diff --git a/module/Rest/test/Middleware/ShortUrl/CreateShortUrlContentNegotiationMiddlewareTest.php b/module/Rest/test/Middleware/ShortUrl/CreateShortUrlContentNegotiationMiddlewareTest.php index c847027c..7f4d0eba 100644 --- a/module/Rest/test/Middleware/ShortUrl/CreateShortUrlContentNegotiationMiddlewareTest.php +++ b/module/Rest/test/Middleware/ShortUrl/CreateShortUrlContentNegotiationMiddlewareTest.php @@ -40,7 +40,7 @@ class CreateShortUrlContentNegotiationMiddlewareTest extends TestCase } #[Test, DataProvider('provideData')] - public function properResponseIsReturned(?string $accept, array $query, string $expectedContentType): void + public function properResponseIsReturned(string|null $accept, array $query, string $expectedContentType): void { $request = (new ServerRequest())->withQueryParams($query); if ($accept !== null) { diff --git a/module/Rest/test/Service/ApiKeyServiceTest.php b/module/Rest/test/Service/ApiKeyServiceTest.php index 45364070..a799da27 100644 --- a/module/Rest/test/Service/ApiKeyServiceTest.php +++ b/module/Rest/test/Service/ApiKeyServiceTest.php @@ -35,7 +35,7 @@ class ApiKeyServiceTest extends TestCase * @param RoleDefinition[] $roles */ #[Test, DataProvider('provideCreationDate')] - public function apiKeyIsProperlyCreated(?Chronos $date, ?string $name, array $roles): void + public function apiKeyIsProperlyCreated(Chronos|null $date, string|null $name, array $roles): void { $this->em->expects($this->once())->method('flush'); $this->em->expects($this->once())->method('persist')->with($this->isInstanceOf(ApiKey::class)); @@ -68,7 +68,7 @@ class ApiKeyServiceTest extends TestCase } #[Test, DataProvider('provideInvalidApiKeys')] - public function checkReturnsFalseForInvalidApiKeys(?ApiKey $invalidKey): void + public function checkReturnsFalseForInvalidApiKeys(ApiKey|null $invalidKey): void { $this->repo->expects($this->once())->method('findOneBy')->with(['key' => '12345'])->willReturn($invalidKey); $this->em->method('getRepository')->with(ApiKey::class)->willReturn($this->repo); @@ -154,7 +154,7 @@ class ApiKeyServiceTest extends TestCase } #[Test, DataProvider('provideInitialApiKeys')] - public function createInitialDelegatesToRepository(?ApiKey $apiKey): void + public function createInitialDelegatesToRepository(ApiKey|null $apiKey): void { $this->repo->expects($this->once())->method('createInitialApiKey')->with('the_key')->willReturn($apiKey); $this->em->method('getRepository')->with(ApiKey::class)->willReturn($this->repo); From 9ccb866e5e16277fd6413843fc628081e30696df Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 28 Oct 2024 22:43:59 +0100 Subject: [PATCH 13/80] Display warnings and deprecations in all test suites --- phpunit-api.xml | 2 ++ phpunit-cli.xml | 2 ++ phpunit-db.xml | 2 ++ phpunit.xml.dist | 1 + 4 files changed, 7 insertions(+) diff --git a/phpunit-api.xml b/phpunit-api.xml index a2f4def8..dc682322 100644 --- a/phpunit-api.xml +++ b/phpunit-api.xml @@ -5,6 +5,8 @@ bootstrap="./config/test/bootstrap_api_tests.php" colors="true" cacheDirectory="build/.phpunit/api-tests.cache" + displayDetailsOnTestsThatTriggerWarnings="true" + displayDetailsOnTestsThatTriggerDeprecations="true" > diff --git a/phpunit-cli.xml b/phpunit-cli.xml index 1eaa0f28..186a9511 100644 --- a/phpunit-cli.xml +++ b/phpunit-cli.xml @@ -5,6 +5,8 @@ bootstrap="./config/test/bootstrap_cli_tests.php" colors="true" cacheDirectory="build/.phpunit/cli-tests.cache" + displayDetailsOnTestsThatTriggerWarnings="true" + displayDetailsOnTestsThatTriggerDeprecations="true" > diff --git a/phpunit-db.xml b/phpunit-db.xml index 17e748b8..f63a2d7e 100644 --- a/phpunit-db.xml +++ b/phpunit-db.xml @@ -5,6 +5,8 @@ bootstrap="./config/test/bootstrap_db_tests.php" colors="true" cacheDirectory="build/.phpunit/db-tests.cache" + displayDetailsOnTestsThatTriggerWarnings="true" + displayDetailsOnTestsThatTriggerDeprecations="true" > diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 30f2286d..4c7b9f10 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -6,6 +6,7 @@ colors="true" cacheDirectory="build/.phpunit/unit-tests.cache" displayDetailsOnTestsThatTriggerWarnings="true" + displayDetailsOnTestsThatTriggerDeprecations="true" > From 98364a1aae2737ebdb316498d400c4b4a68dd33a Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 29 Oct 2024 16:54:53 +0100 Subject: [PATCH 14/80] Update to mlocati/ip-lib 1.18.1 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 3dc9e10b..fc032e70 100644 --- a/composer.json +++ b/composer.json @@ -39,7 +39,7 @@ "mezzio/mezzio": "^3.20", "mezzio/mezzio-fastroute": "^3.12", "mezzio/mezzio-problem-details": "^1.15", - "mlocati/ip-lib": "^1.18", + "mlocati/ip-lib": "^1.18.1", "mobiledetect/mobiledetectlib": "^4.8", "pagerfanta/core": "^3.8", "ramsey/uuid": "^4.7", From d7ecef94f2dde4fcb5b59dc36999fa3dee173407 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 30 Oct 2024 08:25:28 +0100 Subject: [PATCH 15/80] Avoid selecting domains for every short URL in list --- .../Core/src/ShortUrl/Helper/ShortUrlStringifier.php | 12 +++++++----- .../ShortUrl/Helper/ShortUrlStringifierInterface.php | 3 ++- .../Core/src/ShortUrl/Model/ShortUrlIdentifier.php | 6 ++---- .../src/ShortUrl/Model/ShortUrlWithVisitsSummary.php | 11 ++++++++--- .../ShortUrl/Transformer/ShortUrlDataTransformer.php | 11 +++++++---- .../Transformer/ShortUrlDataTransformerInterface.php | 2 +- 6 files changed, 27 insertions(+), 18 deletions(-) diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php b/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php index 6659bc0c..36dd9a60 100644 --- a/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php +++ b/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php @@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Helper; use Laminas\Diactoros\Uri; use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use function sprintf; @@ -18,19 +19,20 @@ readonly class ShortUrlStringifier implements ShortUrlStringifierInterface ) { } - public function stringify(ShortUrl $shortUrl): string + public function stringify(ShortUrl|ShortUrlIdentifier $shortUrl): string { + $shortUrlIdentifier = $shortUrl instanceof ShortUrl ? ShortUrlIdentifier::fromShortUrl($shortUrl) : $shortUrl; $uriWithoutShortCode = (new Uri())->withScheme($this->urlShortenerOptions->schema) - ->withHost($this->resolveDomain($shortUrl)) + ->withHost($this->resolveDomain($shortUrlIdentifier)) ->withPath($this->basePath) ->__toString(); // The short code needs to be appended to avoid it from being URL-encoded - return sprintf('%s/%s', $uriWithoutShortCode, $shortUrl->getShortCode()); + return sprintf('%s/%s', $uriWithoutShortCode, $shortUrlIdentifier->shortCode); } - private function resolveDomain(ShortUrl $shortUrl): string + private function resolveDomain(ShortUrlIdentifier $shortUrlIdentifier): string { - return $shortUrl->getDomain()?->authority ?? $this->urlShortenerOptions->defaultDomain; + return $shortUrlIdentifier->domain ?? $this->urlShortenerOptions->defaultDomain; } } diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlStringifierInterface.php b/module/Core/src/ShortUrl/Helper/ShortUrlStringifierInterface.php index 0505a694..0a6f6975 100644 --- a/module/Core/src/ShortUrl/Helper/ShortUrlStringifierInterface.php +++ b/module/Core/src/ShortUrl/Helper/ShortUrlStringifierInterface.php @@ -5,8 +5,9 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl\Helper; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; interface ShortUrlStringifierInterface { - public function stringify(ShortUrl $shortUrl): string; + public function stringify(ShortUrl|ShortUrlIdentifier $shortUrl): string; } diff --git a/module/Core/src/ShortUrl/Model/ShortUrlIdentifier.php b/module/Core/src/ShortUrl/Model/ShortUrlIdentifier.php index ff44ed7f..9b3014f8 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlIdentifier.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlIdentifier.php @@ -33,10 +33,8 @@ final readonly class ShortUrlIdentifier public static function fromShortUrl(ShortUrl $shortUrl): self { - $domain = $shortUrl->getDomain(); - $domainAuthority = $domain?->authority; - - return new self($shortUrl->getShortCode(), $domainAuthority); + $domain = $shortUrl->getDomain()?->authority; + return new self($shortUrl->getShortCode(), $domain); } public static function fromShortCodeAndDomain(string $shortCode, string|null $domain = null): self diff --git a/module/Core/src/ShortUrl/Model/ShortUrlWithVisitsSummary.php b/module/Core/src/ShortUrl/Model/ShortUrlWithVisitsSummary.php index d5c34b8b..6b824cf8 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlWithVisitsSummary.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlWithVisitsSummary.php @@ -11,8 +11,8 @@ final readonly class ShortUrlWithVisitsSummary { private function __construct( public ShortUrl $shortUrl, + private string|null $authority, private VisitsSummary|null $visitsSummary = null, - private string|null $authority = null, ) { } @@ -23,17 +23,22 @@ final readonly class ShortUrlWithVisitsSummary { return new self( shortUrl: $data['shortUrl'], + authority: $data['authority'] ?? null, visitsSummary: VisitsSummary::fromTotalAndNonBots( total: (int) $data['visits'], nonBots: (int) $data['nonBotVisits'], ), - authority: $data['authority'] ?? null, ); } public static function fromShortUrl(ShortUrl $shortUrl): self { - return new self($shortUrl); + return new self($shortUrl, authority: $shortUrl->getDomain()?->authority); + } + + public function toIdentifier(): ShortUrlIdentifier + { + return ShortUrlIdentifier::fromShortCodeAndDomain($this->shortUrl->getShortCode(), $this->authority); } public function toArray(): array diff --git a/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php b/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php index d2bdb73a..d19262e1 100644 --- a/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php +++ b/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php @@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Transformer; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary; readonly class ShortUrlDataTransformer implements ShortUrlDataTransformerInterface @@ -14,12 +15,14 @@ readonly class ShortUrlDataTransformer implements ShortUrlDataTransformerInterfa { } - public function transform(ShortUrlWithVisitsSummary|ShortUrl $data): array + public function transform(ShortUrlWithVisitsSummary|ShortUrl $shortUrl): array { - $shortUrl = $data instanceof ShortUrlWithVisitsSummary ? $data->shortUrl : $data; + $shortUrlIdentifier = $shortUrl instanceof ShortUrl + ? ShortUrlIdentifier::fromShortUrl($shortUrl) + : $shortUrl->toIdentifier(); return [ - 'shortUrl' => $this->stringifier->stringify($shortUrl), - ...$data->toArray(), + 'shortUrl' => $this->stringifier->stringify($shortUrlIdentifier), + ...$shortUrl->toArray(), ]; } } diff --git a/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformerInterface.php b/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformerInterface.php index e1101f70..b3103c80 100644 --- a/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformerInterface.php +++ b/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformerInterface.php @@ -9,5 +9,5 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary; interface ShortUrlDataTransformerInterface { - public function transform(ShortUrlWithVisitsSummary|ShortUrl $data): array; + public function transform(ShortUrlWithVisitsSummary|ShortUrl $shortUrl): array; } From eae001a34a7ef1324da3966f3a13087c03959f42 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 30 Oct 2024 08:28:34 +0100 Subject: [PATCH 16/80] Rename ShortUrlWithVisitsSummary to ShortUrlWithDeps --- module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php | 6 +++--- .../test/Command/ShortUrl/ListShortUrlsCommandTest.php | 8 ++++---- ...ShortUrlWithVisitsSummary.php => ShortUrlWithDeps.php} | 2 +- .../Paginator/Adapter/ShortUrlRepositoryAdapter.php | 4 ++-- .../src/ShortUrl/Repository/ShortUrlListRepository.php | 6 +++--- .../Repository/ShortUrlListRepositoryInterface.php | 4 ++-- module/Core/src/ShortUrl/ShortUrlListServiceInterface.php | 4 ++-- .../src/ShortUrl/Transformer/ShortUrlDataTransformer.php | 4 ++-- .../Transformer/ShortUrlDataTransformerInterface.php | 4 ++-- .../ShortUrl/Repository/ShortUrlListRepositoryTest.php | 4 ++-- 10 files changed, 23 insertions(+), 23 deletions(-) rename module/Core/src/ShortUrl/Model/{ShortUrlWithVisitsSummary.php => ShortUrlWithDeps.php} (96%) diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index d7243dfb..72a92fe8 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -13,7 +13,7 @@ use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtils; use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams; -use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithDeps; use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlsParamsInputFilter; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlListServiceInterface; @@ -186,7 +186,7 @@ class ListShortUrlsCommand extends Command /** * @param array $columnsMap - * @return Paginator + * @return Paginator */ private function renderPage( OutputInterface $output, @@ -196,7 +196,7 @@ class ListShortUrlsCommand extends Command ): Paginator { $shortUrls = $this->shortUrlService->listShortUrls($params); - $rows = map([...$shortUrls], function (ShortUrlWithVisitsSummary $shortUrl) use ($columnsMap) { + $rows = map([...$shortUrls], function (ShortUrlWithDeps $shortUrl) use ($columnsMap) { $serializedShortUrl = $this->transformer->transform($shortUrl); return map($columnsMap, fn (callable $call) => $call($serializedShortUrl, $shortUrl->shortUrl)); }); diff --git a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php index ccdab885..41b3fe88 100644 --- a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php @@ -16,7 +16,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams; -use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithDeps; use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlListServiceInterface; use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer; @@ -48,7 +48,7 @@ class ListShortUrlsCommandTest extends TestCase // The paginator will return more than one page $data = []; for ($i = 0; $i < 50; $i++) { - $data[] = ShortUrlWithVisitsSummary::fromShortUrl(ShortUrl::withLongUrl('https://url_' . $i)); + $data[] = ShortUrlWithDeps::fromShortUrl(ShortUrl::withLongUrl('https://url_' . $i)); } $this->shortUrlService->expects($this->exactly(3))->method('listShortUrls')->withAnyParameters() @@ -70,7 +70,7 @@ class ListShortUrlsCommandTest extends TestCase // The paginator will return more than one page $data = []; for ($i = 0; $i < 30; $i++) { - $data[] = ShortUrlWithVisitsSummary::fromShortUrl(ShortUrl::withLongUrl('https://url_' . $i)); + $data[] = ShortUrlWithDeps::fromShortUrl(ShortUrl::withLongUrl('https://url_' . $i)); } $this->shortUrlService->expects($this->once())->method('listShortUrls')->with( @@ -112,7 +112,7 @@ class ListShortUrlsCommandTest extends TestCase $this->shortUrlService->expects($this->once())->method('listShortUrls')->with( ShortUrlsParams::empty(), )->willReturn(new Paginator(new ArrayAdapter([ - ShortUrlWithVisitsSummary::fromShortUrl( + ShortUrlWithDeps::fromShortUrl( ShortUrl::create(ShortUrlCreation::fromRawData([ 'longUrl' => 'https://foo.com', 'tags' => ['foo', 'bar', 'baz'], diff --git a/module/Core/src/ShortUrl/Model/ShortUrlWithVisitsSummary.php b/module/Core/src/ShortUrl/Model/ShortUrlWithDeps.php similarity index 96% rename from module/Core/src/ShortUrl/Model/ShortUrlWithVisitsSummary.php rename to module/Core/src/ShortUrl/Model/ShortUrlWithDeps.php index 6b824cf8..4b9b5a70 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlWithVisitsSummary.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlWithDeps.php @@ -7,7 +7,7 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Model; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\Visit\Model\VisitsSummary; -final readonly class ShortUrlWithVisitsSummary +final readonly class ShortUrlWithDeps { private function __construct( public ShortUrl $shortUrl, diff --git a/module/Core/src/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapter.php b/module/Core/src/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapter.php index 1a7b97de..4daa5cb9 100644 --- a/module/Core/src/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapter.php +++ b/module/Core/src/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapter.php @@ -6,13 +6,13 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Paginator\Adapter; use Pagerfanta\Adapter\AdapterInterface; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams; -use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithDeps; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering; use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlListRepositoryInterface; use Shlinkio\Shlink\Rest\Entity\ApiKey; -/** @implements AdapterInterface */ +/** @implements AdapterInterface */ readonly class ShortUrlRepositoryAdapter implements AdapterInterface { public function __construct( diff --git a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php index 6749a03f..c18b31ef 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php @@ -12,7 +12,7 @@ use Shlinkio\Shlink\Common\Doctrine\Type\ChronosDateTimeType; use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\OrderableField; -use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithDeps; use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering; @@ -25,7 +25,7 @@ use function sprintf; class ShortUrlListRepository extends EntitySpecificationRepository implements ShortUrlListRepositoryInterface { /** - * @return ShortUrlWithVisitsSummary[] + * @return ShortUrlWithDeps[] */ public function findList(ShortUrlsListFiltering $filtering): array { @@ -59,7 +59,7 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh /** @var array{shortUrl: ShortUrl, visits: string, nonBotVisits: string, authority: string|null}[] $result */ $result = $qb->getQuery()->getResult(); - return map($result, static fn (array $s) => ShortUrlWithVisitsSummary::fromArray($s)); + return map($result, static fn (array $s) => ShortUrlWithDeps::fromArray($s)); } private function processOrderByForList(QueryBuilder $qb, ShortUrlsListFiltering $filtering): void diff --git a/module/Core/src/ShortUrl/Repository/ShortUrlListRepositoryInterface.php b/module/Core/src/ShortUrl/Repository/ShortUrlListRepositoryInterface.php index db3f8017..d71f6297 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlListRepositoryInterface.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlListRepositoryInterface.php @@ -4,14 +4,14 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl\Repository; -use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithDeps; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering; interface ShortUrlListRepositoryInterface { /** - * @return ShortUrlWithVisitsSummary[] + * @return ShortUrlWithDeps[] */ public function findList(ShortUrlsListFiltering $filtering): array; diff --git a/module/Core/src/ShortUrl/ShortUrlListServiceInterface.php b/module/Core/src/ShortUrl/ShortUrlListServiceInterface.php index a8b8b2cc..9ece5cad 100644 --- a/module/Core/src/ShortUrl/ShortUrlListServiceInterface.php +++ b/module/Core/src/ShortUrl/ShortUrlListServiceInterface.php @@ -6,13 +6,13 @@ namespace Shlinkio\Shlink\Core\ShortUrl; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams; -use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithDeps; use Shlinkio\Shlink\Rest\Entity\ApiKey; interface ShortUrlListServiceInterface { /** - * @return Paginator + * @return Paginator */ public function listShortUrls(ShortUrlsParams $params, ApiKey|null $apiKey = null): Paginator; } diff --git a/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php b/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php index d19262e1..2692f76b 100644 --- a/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php +++ b/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php @@ -7,7 +7,7 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Transformer; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; -use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithDeps; readonly class ShortUrlDataTransformer implements ShortUrlDataTransformerInterface { @@ -15,7 +15,7 @@ readonly class ShortUrlDataTransformer implements ShortUrlDataTransformerInterfa { } - public function transform(ShortUrlWithVisitsSummary|ShortUrl $shortUrl): array + public function transform(ShortUrlWithDeps|ShortUrl $shortUrl): array { $shortUrlIdentifier = $shortUrl instanceof ShortUrl ? ShortUrlIdentifier::fromShortUrl($shortUrl) diff --git a/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformerInterface.php b/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformerInterface.php index b3103c80..cd8aeb37 100644 --- a/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformerInterface.php +++ b/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformerInterface.php @@ -5,9 +5,9 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl\Transformer; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; -use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithDeps; interface ShortUrlDataTransformerInterface { - public function transform(ShortUrlWithVisitsSummary|ShortUrl $shortUrl): array; + public function transform(ShortUrlWithDeps|ShortUrl $shortUrl): array; } diff --git a/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php b/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php index 995f7218..05109365 100644 --- a/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php +++ b/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php @@ -14,7 +14,7 @@ use Shlinkio\Shlink\Core\Model\Ordering; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\OrderableField; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; -use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithDeps; use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering; @@ -97,7 +97,7 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase $result = $this->repo->findList(new ShortUrlsListFiltering(searchTerm: 'bar')); self::assertCount(2, $result); self::assertEquals(2, $this->repo->countList(new ShortUrlsCountFiltering('bar'))); - self::assertContains($foo, map($result, fn (ShortUrlWithVisitsSummary $s) => $s->shortUrl)); + self::assertContains($foo, map($result, fn (ShortUrlWithDeps $s) => $s->shortUrl)); $result = $this->repo->findList(new ShortUrlsListFiltering()); self::assertCount(3, $result); From 1fd7d580848743ff9a0fef8f976ac9513d83fafc Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 1 Nov 2024 10:49:53 +0100 Subject: [PATCH 17/80] Update Bluesky handle --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 77cbaa43..681a9495 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![License](https://img.shields.io/github/license/shlinkio/shlink.svg?style=flat-square)](https://github.com/shlinkio/shlink/blob/main/LICENSE) [![Mastodon](https://img.shields.io/mastodon/follow/109329425426175098?color=%236364ff&domain=https%3A%2F%2Ffosstodon.org&label=follow&logo=mastodon&logoColor=white&style=flat-square)](https://fosstodon.org/@shlinkio) -[![Bluesky](https://img.shields.io/badge/follow-shlinkio-0285FF.svg?style=flat-square&logo=bluesky&logoColor=white)](https://bsky.app/profile/shlinkio.bsky.social) +[![Bluesky](https://img.shields.io/badge/follow-shlinkio-0285FF.svg?style=flat-square&logo=bluesky&logoColor=white)](https://bsky.app/profile/shlink.io) [![Paypal donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=aaaaaa)](https://slnk.to/donate) A PHP-based self-hosted URL shortener that can be used to serve shortened URLs under your own domain. From 3085fa76cf2391479034c88f02ebaa992404d2df Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 4 Nov 2024 08:50:58 +0100 Subject: [PATCH 18/80] Update to hidehalo/nanoid-php 2.0 --- CHANGELOG.md | 1 + composer.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58c396ac..a6741b55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ### Changed * Update to Shlink PHP coding standard 2.4 +* Update to `hidehalo/nanoid-php` 2.0 ### Deprecated * *Nothing* diff --git a/composer.json b/composer.json index fc032e70..1100f099 100644 --- a/composer.json +++ b/composer.json @@ -27,7 +27,7 @@ "friendsofphp/proxy-manager-lts": "^1.0", "geoip2/geoip2": "^3.0", "guzzlehttp/guzzle": "^7.9", - "hidehalo/nanoid-php": "^1.1", + "hidehalo/nanoid-php": "^2.0", "jaybizzle/crawler-detect": "^1.2.116", "laminas/laminas-config": "^3.9", "laminas/laminas-config-aggregator": "^1.15", From 79c5418ac2935880cf1a481d75ee5d5b5b4fc920 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 4 Nov 2024 14:22:39 +0100 Subject: [PATCH 19/80] Simplify ApiKey entity by exposing key as a readonly prop --- .../src/Command/Api/GenerateKeyCommand.php | 2 +- .../CLI/src/Command/Api/ListKeysCommand.php | 2 +- .../Command/ShortUrl/ListShortUrlsCommand.php | 2 +- .../test/Command/Api/ListKeysCommandTest.php | 30 +++++++++---------- .../ShortUrl/ListShortUrlsCommandTest.php | 2 +- module/Rest/src/ApiKey/Model/ApiKeyMeta.php | 10 +++---- module/Rest/src/Entity/ApiKey.php | 13 +------- .../Rest/test-api/Fixtures/ApiKeyFixture.php | 8 +---- .../AuthenticationMiddlewareTest.php | 5 ++-- 9 files changed, 28 insertions(+), 46 deletions(-) diff --git a/module/CLI/src/Command/Api/GenerateKeyCommand.php b/module/CLI/src/Command/Api/GenerateKeyCommand.php index 0a35bef7..a7656189 100644 --- a/module/CLI/src/Command/Api/GenerateKeyCommand.php +++ b/module/CLI/src/Command/Api/GenerateKeyCommand.php @@ -109,7 +109,7 @@ class GenerateKeyCommand extends Command )); $io = new SymfonyStyle($input, $output); - $io->success(sprintf('Generated API key: "%s"', $apiKey->toString())); + $io->success(sprintf('Generated API key: "%s"', $apiKey->key)); if (! ApiKey::isAdmin($apiKey)) { ShlinkTable::default($io)->render( diff --git a/module/CLI/src/Command/Api/ListKeysCommand.php b/module/CLI/src/Command/Api/ListKeysCommand.php index 40ae8eef..ab10ebc6 100644 --- a/module/CLI/src/Command/Api/ListKeysCommand.php +++ b/module/CLI/src/Command/Api/ListKeysCommand.php @@ -54,7 +54,7 @@ class ListKeysCommand extends Command $messagePattern = $this->determineMessagePattern($apiKey); // Set columns for this row - $rowData = [sprintf($messagePattern, $apiKey), sprintf($messagePattern, $apiKey->name ?? '-')]; + $rowData = [sprintf($messagePattern, $apiKey->key), sprintf($messagePattern, $apiKey->name ?? '-')]; if (! $enabledOnly) { $rowData[] = sprintf($messagePattern, $this->getEnabledSymbol($apiKey)); } diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index 72a92fe8..900402e1 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -244,7 +244,7 @@ class ListShortUrlsCommand extends Command } if ($input->getOption('show-api-key')) { $columnsMap['API Key'] = static fn (array $_, ShortUrl $shortUrl): string => - $shortUrl->authorApiKey?->__toString() ?? ''; + $shortUrl->authorApiKey?->key ?? ''; } if ($input->getOption('show-api-key-name')) { $columnsMap['API Key Name'] = static fn (array $_, ShortUrl $shortUrl): string|null => diff --git a/module/CLI/test/Command/Api/ListKeysCommandTest.php b/module/CLI/test/Command/Api/ListKeysCommandTest.php index 478dbaa5..6f4b816b 100644 --- a/module/CLI/test/Command/Api/ListKeysCommandTest.php +++ b/module/CLI/test/Command/Api/ListKeysCommandTest.php @@ -55,11 +55,11 @@ class ListKeysCommandTest extends TestCase +--------------------------------------+------+------------+---------------------------+-------+ | Key | Name | Is enabled | Expiration date | Roles | +--------------------------------------+------+------------+---------------------------+-------+ - | {$apiKey1} | - | --- | - | Admin | + | {$apiKey1->key} | - | --- | - | Admin | +--------------------------------------+------+------------+---------------------------+-------+ - | {$apiKey2} | - | --- | 2020-01-01T00:00:00+00:00 | Admin | + | {$apiKey2->key} | - | --- | 2020-01-01T00:00:00+00:00 | Admin | +--------------------------------------+------+------------+---------------------------+-------+ - | {$apiKey3} | - | +++ | - | Admin | + | {$apiKey3->key} | - | +++ | - | Admin | +--------------------------------------+------+------------+---------------------------+-------+ OUTPUT, @@ -71,9 +71,9 @@ class ListKeysCommandTest extends TestCase +--------------------------------------+------+-----------------+-------+ | Key | Name | Expiration date | Roles | +--------------------------------------+------+-----------------+-------+ - | {$apiKey1} | - | - | Admin | + | {$apiKey1->key} | - | - | Admin | +--------------------------------------+------+-----------------+-------+ - | {$apiKey2} | - | - | Admin | + | {$apiKey2->key} | - | - | Admin | +--------------------------------------+------+-----------------+-------+ OUTPUT, @@ -97,18 +97,18 @@ class ListKeysCommandTest extends TestCase +--------------------------------------+------+-----------------+--------------------------+ | Key | Name | Expiration date | Roles | +--------------------------------------+------+-----------------+--------------------------+ - | {$apiKey1} | - | - | Admin | + | {$apiKey1->key} | - | - | Admin | +--------------------------------------+------+-----------------+--------------------------+ - | {$apiKey2} | - | - | Author only | + | {$apiKey2->key} | - | - | Author only | +--------------------------------------+------+-----------------+--------------------------+ - | {$apiKey3} | - | - | Domain only: example.com | + | {$apiKey3->key} | - | - | Domain only: example.com | +--------------------------------------+------+-----------------+--------------------------+ - | {$apiKey4} | - | - | Admin | + | {$apiKey4->key} | - | - | Admin | +--------------------------------------+------+-----------------+--------------------------+ - | {$apiKey5} | - | - | Author only | + | {$apiKey5->key} | - | - | Author only | | | | | Domain only: example.com | +--------------------------------------+------+-----------------+--------------------------+ - | {$apiKey6} | - | - | Admin | + | {$apiKey6->key} | - | - | Admin | +--------------------------------------+------+-----------------+--------------------------+ OUTPUT, @@ -125,13 +125,13 @@ class ListKeysCommandTest extends TestCase +--------------------------------------+---------------+-----------------+-------+ | Key | Name | Expiration date | Roles | +--------------------------------------+---------------+-----------------+-------+ - | {$apiKey1} | Alice | - | Admin | + | {$apiKey1->key} | Alice | - | Admin | +--------------------------------------+---------------+-----------------+-------+ - | {$apiKey2} | Alice and Bob | - | Admin | + | {$apiKey2->key} | Alice and Bob | - | Admin | +--------------------------------------+---------------+-----------------+-------+ - | {$apiKey3} | | - | Admin | + | {$apiKey3->key} | | - | Admin | +--------------------------------------+---------------+-----------------+-------+ - | {$apiKey4} | - | - | Admin | + | {$apiKey4->key} | - | - | Admin | +--------------------------------------+---------------+-----------------+-------+ OUTPUT, diff --git a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php index 41b3fe88..3b84d175 100644 --- a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php @@ -140,7 +140,7 @@ class ListShortUrlsCommandTest extends TestCase public static function provideOptionalFlags(): iterable { $apiKey = ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'my api key')); - $key = $apiKey->toString(); + $key = $apiKey->key; yield 'tags only' => [ ['--show-tags' => true], diff --git a/module/Rest/src/ApiKey/Model/ApiKeyMeta.php b/module/Rest/src/ApiKey/Model/ApiKeyMeta.php index 020c1f27..04b37214 100644 --- a/module/Rest/src/ApiKey/Model/ApiKeyMeta.php +++ b/module/Rest/src/ApiKey/Model/ApiKeyMeta.php @@ -7,16 +7,16 @@ namespace Shlinkio\Shlink\Rest\ApiKey\Model; use Cake\Chronos\Chronos; use Ramsey\Uuid\Uuid; -final class ApiKeyMeta +final readonly class ApiKeyMeta { /** * @param iterable $roleDefinitions */ private function __construct( - public readonly string $key, - public readonly string|null $name, - public readonly Chronos|null $expirationDate, - public readonly iterable $roleDefinitions, + public string $key, + public string|null $name, + public Chronos|null $expirationDate, + public iterable $roleDefinitions, ) { } diff --git a/module/Rest/src/Entity/ApiKey.php b/module/Rest/src/Entity/ApiKey.php index 1cca4f3a..4f7575c5 100644 --- a/module/Rest/src/Entity/ApiKey.php +++ b/module/Rest/src/Entity/ApiKey.php @@ -19,10 +19,9 @@ class ApiKey extends AbstractEntity { /** * @param Collection $roles - * @throws Exception */ private function __construct( - private string $key, + public readonly string $key, public readonly string|null $name = null, public readonly Chronos|null $expirationDate = null, private bool $enabled = true, @@ -75,16 +74,6 @@ class ApiKey extends AbstractEntity return $this->isEnabled() && ! $this->isExpired(); } - public function __toString(): string - { - return $this->key; - } - - public function toString(): string - { - return $this->key; - } - public function spec(string|null $context = null): Specification { $specs = $this->roles->map(fn (ApiKeyRole $role) => Role::toSpec($role, $context))->getValues(); diff --git a/module/Rest/test-api/Fixtures/ApiKeyFixture.php b/module/Rest/test-api/Fixtures/ApiKeyFixture.php index 1b4f64ea..c734e342 100644 --- a/module/Rest/test-api/Fixtures/ApiKeyFixture.php +++ b/module/Rest/test-api/Fixtures/ApiKeyFixture.php @@ -8,7 +8,6 @@ use Cake\Chronos\Chronos; use Doctrine\Common\DataFixtures\AbstractFixture; use Doctrine\Common\DataFixtures\DependentFixtureInterface; use Doctrine\Persistence\ObjectManager; -use ReflectionObject; use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; @@ -51,12 +50,7 @@ class ApiKeyFixture extends AbstractFixture implements DependentFixtureInterface private function buildApiKey(string $key, bool $enabled, Chronos|null $expiresAt = null): ApiKey { - $apiKey = ApiKey::fromMeta(ApiKeyMeta::fromParams(expirationDate: $expiresAt)); - $ref = new ReflectionObject($apiKey); - $keyProp = $ref->getProperty('key'); - $keyProp->setAccessible(true); - $keyProp->setValue($apiKey, $key); - + $apiKey = ApiKey::fromMeta(ApiKeyMeta::fromParams(key: $key, expirationDate: $expiresAt)); if (! $enabled) { $apiKey->disable(); } diff --git a/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php b/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php index 99a8b3e6..5c530480 100644 --- a/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php +++ b/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php @@ -130,18 +130,17 @@ class AuthenticationMiddlewareTest extends TestCase public function validApiKeyFallsBackToNextMiddleware(): void { $apiKey = ApiKey::create(); - $key = $apiKey->toString(); $request = ServerRequestFactory::fromGlobals() ->withAttribute( RouteResult::class, RouteResult::fromRoute(new Route('bar', self::getDummyMiddleware()), []), ) - ->withHeader('X-Api-Key', $key); + ->withHeader('X-Api-Key', $apiKey->key); $this->handler->expects($this->once())->method('handle')->with( $request->withAttribute(ApiKey::class, $apiKey), )->willReturn(new Response()); - $this->apiKeyService->expects($this->once())->method('check')->with($key)->willReturn( + $this->apiKeyService->expects($this->once())->method('check')->with($apiKey->key)->willReturn( new ApiKeyCheckResult($apiKey), ); From 819a535bfe3bcca8164d530be2f930e8763cdd13 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 5 Nov 2024 11:08:11 +0100 Subject: [PATCH 20/80] Create migration to set API keys in name column --- .../Core/migrations/Version20241105094747.php | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 module/Core/migrations/Version20241105094747.php diff --git a/module/Core/migrations/Version20241105094747.php b/module/Core/migrations/Version20241105094747.php new file mode 100644 index 00000000..4ce5548b --- /dev/null +++ b/module/Core/migrations/Version20241105094747.php @@ -0,0 +1,40 @@ +connection->quoteIdentifier('key'); + + // Append key to the name for all API keys that already have a name + $qb = $this->connection->createQueryBuilder(); + $qb->update('api_keys') + ->set('name', 'CONCAT(name, ' . $this->connection->quote(' - ') . ', ' . $keyColumnName . ')') + ->where($qb->expr()->isNotNull('name')); + $qb->executeStatement(); + + // Set plain key as name for all API keys without a name + $qb = $this->connection->createQueryBuilder(); + $qb->update('api_keys') + ->set('name', $keyColumnName) + ->where($qb->expr()->isNull('name')); + $qb->executeStatement(); + } + + public function isTransactional(): bool + { + return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); + } +} From a094be2b9ef7f85b3ef8878140c28fcda19cbd11 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 5 Nov 2024 11:26:39 +0100 Subject: [PATCH 21/80] Fall back API key names to auto-generated keys --- .../src/Command/Api/GenerateKeyCommand.php | 13 ++- .../CLI/src/Command/Api/ListKeysCommand.php | 3 +- .../CLI/test-cli/Command/ListApiKeysTest.php | 56 ++++++------ .../Command/Api/GenerateKeyCommandTest.php | 2 +- .../test/Command/Api/ListKeysCommandTest.php | 90 +++++++++---------- module/Rest/src/ApiKey/Model/ApiKeyMeta.php | 18 +++- .../ApiKey/Repository/ApiKeyRepository.php | 23 +++-- module/Rest/src/Entity/ApiKey.php | 11 +++ module/Rest/src/Service/ApiKeyCheckResult.php | 4 +- module/Rest/src/Service/ApiKeyService.php | 18 ++-- .../Rest/test/Service/ApiKeyServiceTest.php | 12 ++- 11 files changed, 139 insertions(+), 111 deletions(-) diff --git a/module/CLI/src/Command/Api/GenerateKeyCommand.php b/module/CLI/src/Command/Api/GenerateKeyCommand.php index a7656189..a6b8bad0 100644 --- a/module/CLI/src/Command/Api/GenerateKeyCommand.php +++ b/module/CLI/src/Command/Api/GenerateKeyCommand.php @@ -102,19 +102,24 @@ class GenerateKeyCommand extends Command { $expirationDate = $input->getOption('expiration-date'); - $apiKey = $this->apiKeyService->create(ApiKeyMeta::fromParams( + $apiKeyMeta = ApiKeyMeta::fromParams( name: $input->getOption('name'), expirationDate: isset($expirationDate) ? Chronos::parse($expirationDate) : null, roleDefinitions: $this->roleResolver->determineRoles($input), - )); + ); + $apiKey = $this->apiKeyService->create($apiKeyMeta); $io = new SymfonyStyle($input, $output); - $io->success(sprintf('Generated API key: "%s"', $apiKey->key)); + $io->success(sprintf('Generated API key: "%s"', $apiKeyMeta->key)); + + if ($input->isInteractive()) { + $io->warning('Save the key in a secure location. You will not be able to get it afterwards.'); + } if (! ApiKey::isAdmin($apiKey)) { ShlinkTable::default($io)->render( ['Role name', 'Role metadata'], - $apiKey->mapRoles(fn (Role $role, array $meta) => [$role->value, arrayToString($meta, 0)]), + $apiKey->mapRoles(fn (Role $role, array $meta) => [$role->value, arrayToString($meta, indentSize: 0)]), null, 'Roles', ); diff --git a/module/CLI/src/Command/Api/ListKeysCommand.php b/module/CLI/src/Command/Api/ListKeysCommand.php index ab10ebc6..d341389d 100644 --- a/module/CLI/src/Command/Api/ListKeysCommand.php +++ b/module/CLI/src/Command/Api/ListKeysCommand.php @@ -54,7 +54,7 @@ class ListKeysCommand extends Command $messagePattern = $this->determineMessagePattern($apiKey); // Set columns for this row - $rowData = [sprintf($messagePattern, $apiKey->key), sprintf($messagePattern, $apiKey->name ?? '-')]; + $rowData = [sprintf($messagePattern, $apiKey->name ?? '-')]; if (! $enabledOnly) { $rowData[] = sprintf($messagePattern, $this->getEnabledSymbol($apiKey)); } @@ -67,7 +67,6 @@ class ListKeysCommand extends Command }, $this->apiKeyService->listKeys($enabledOnly)); ShlinkTable::withRowSeparators($output)->render(array_filter([ - 'Key', 'Name', ! $enabledOnly ? 'Is enabled' : null, 'Expiration date', diff --git a/module/CLI/test-cli/Command/ListApiKeysTest.php b/module/CLI/test-cli/Command/ListApiKeysTest.php index 46e3c135..9e0ce90d 100644 --- a/module/CLI/test-cli/Command/ListApiKeysTest.php +++ b/module/CLI/test-cli/Command/ListApiKeysTest.php @@ -26,38 +26,38 @@ class ListApiKeysTest extends CliTestCase { $expiredApiKeyDate = Chronos::now()->subDays(1)->startOfDay()->toAtomString(); $enabledOnlyOutput = << [[], << [['-e'], $enabledOnlyOutput]; diff --git a/module/CLI/test/Command/Api/GenerateKeyCommandTest.php b/module/CLI/test/Command/Api/GenerateKeyCommandTest.php index a15ad667..9c1d337e 100644 --- a/module/CLI/test/Command/Api/GenerateKeyCommandTest.php +++ b/module/CLI/test/Command/Api/GenerateKeyCommandTest.php @@ -36,7 +36,7 @@ class GenerateKeyCommandTest extends TestCase public function noExpirationDateIsDefinedIfNotProvided(): void { $this->apiKeyService->expects($this->once())->method('create')->with( - $this->callback(fn (ApiKeyMeta $meta) => $meta->name === null && $meta->expirationDate === null), + $this->callback(fn (ApiKeyMeta $meta) => $meta->expirationDate === null), )->willReturn(ApiKey::create()); $this->commandTester->execute([]); diff --git a/module/CLI/test/Command/Api/ListKeysCommandTest.php b/module/CLI/test/Command/Api/ListKeysCommandTest.php index 6f4b816b..54ae4c3e 100644 --- a/module/CLI/test/Command/Api/ListKeysCommandTest.php +++ b/module/CLI/test/Command/Api/ListKeysCommandTest.php @@ -52,15 +52,15 @@ class ListKeysCommandTest extends TestCase ], false, <<key} | - | --- | - | Admin | - +--------------------------------------+------+------------+---------------------------+-------+ - | {$apiKey2->key} | - | --- | 2020-01-01T00:00:00+00:00 | Admin | - +--------------------------------------+------+------------+---------------------------+-------+ - | {$apiKey3->key} | - | +++ | - | Admin | - +--------------------------------------+------+------------+---------------------------+-------+ + +--------------------------------------+------------+---------------------------+-------+ + | Name | Is enabled | Expiration date | Roles | + +--------------------------------------+------------+---------------------------+-------+ + | {$apiKey1->name} | --- | - | Admin | + +--------------------------------------+------------+---------------------------+-------+ + | {$apiKey2->name} | --- | 2020-01-01T00:00:00+00:00 | Admin | + +--------------------------------------+------------+---------------------------+-------+ + | {$apiKey3->name} | +++ | - | Admin | + +--------------------------------------+------------+---------------------------+-------+ OUTPUT, ]; @@ -68,13 +68,13 @@ class ListKeysCommandTest extends TestCase [$apiKey1 = ApiKey::create()->disable(), $apiKey2 = ApiKey::create()], true, <<key} | - | - | Admin | - +--------------------------------------+------+-----------------+-------+ - | {$apiKey2->key} | - | - | Admin | - +--------------------------------------+------+-----------------+-------+ + +--------------------------------------+-----------------+-------+ + | Name | Expiration date | Roles | + +--------------------------------------+-----------------+-------+ + | {$apiKey1->name} | - | Admin | + +--------------------------------------+-----------------+-------+ + | {$apiKey2->name} | - | Admin | + +--------------------------------------+-----------------+-------+ OUTPUT, ]; @@ -94,45 +94,45 @@ class ListKeysCommandTest extends TestCase ], true, <<key} | - | - | Admin | - +--------------------------------------+------+-----------------+--------------------------+ - | {$apiKey2->key} | - | - | Author only | - +--------------------------------------+------+-----------------+--------------------------+ - | {$apiKey3->key} | - | - | Domain only: example.com | - +--------------------------------------+------+-----------------+--------------------------+ - | {$apiKey4->key} | - | - | Admin | - +--------------------------------------+------+-----------------+--------------------------+ - | {$apiKey5->key} | - | - | Author only | - | | | | Domain only: example.com | - +--------------------------------------+------+-----------------+--------------------------+ - | {$apiKey6->key} | - | - | Admin | - +--------------------------------------+------+-----------------+--------------------------+ + +--------------------------------------+-----------------+--------------------------+ + | Name | Expiration date | Roles | + +--------------------------------------+-----------------+--------------------------+ + | {$apiKey1->name} | - | Admin | + +--------------------------------------+-----------------+--------------------------+ + | {$apiKey2->name} | - | Author only | + +--------------------------------------+-----------------+--------------------------+ + | {$apiKey3->name} | - | Domain only: example.com | + +--------------------------------------+-----------------+--------------------------+ + | {$apiKey4->name} | - | Admin | + +--------------------------------------+-----------------+--------------------------+ + | {$apiKey5->name} | - | Author only | + | | | Domain only: example.com | + +--------------------------------------+-----------------+--------------------------+ + | {$apiKey6->name} | - | Admin | + +--------------------------------------+-----------------+--------------------------+ OUTPUT, ]; yield 'with names' => [ [ - $apiKey1 = ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'Alice')), - $apiKey2 = ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'Alice and Bob')), + ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'Alice')), + ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'Alice and Bob')), $apiKey3 = ApiKey::fromMeta(ApiKeyMeta::fromParams(name: '')), $apiKey4 = ApiKey::create(), ], true, <<key} | Alice | - | Admin | - +--------------------------------------+---------------+-----------------+-------+ - | {$apiKey2->key} | Alice and Bob | - | Admin | - +--------------------------------------+---------------+-----------------+-------+ - | {$apiKey3->key} | | - | Admin | - +--------------------------------------+---------------+-----------------+-------+ - | {$apiKey4->key} | - | - | Admin | - +--------------------------------------+---------------+-----------------+-------+ + +--------------------------------------+-----------------+-------+ + | Name | Expiration date | Roles | + +--------------------------------------+-----------------+-------+ + | Alice | - | Admin | + +--------------------------------------+-----------------+-------+ + | Alice and Bob | - | Admin | + +--------------------------------------+-----------------+-------+ + | {$apiKey3->name} | - | Admin | + +--------------------------------------+-----------------+-------+ + | {$apiKey4->name} | - | Admin | + +--------------------------------------+-----------------+-------+ OUTPUT, ]; diff --git a/module/Rest/src/ApiKey/Model/ApiKeyMeta.php b/module/Rest/src/ApiKey/Model/ApiKeyMeta.php index 04b37214..21efe16a 100644 --- a/module/Rest/src/ApiKey/Model/ApiKeyMeta.php +++ b/module/Rest/src/ApiKey/Model/ApiKeyMeta.php @@ -7,6 +7,9 @@ namespace Shlinkio\Shlink\Rest\ApiKey\Model; use Cake\Chronos\Chronos; use Ramsey\Uuid\Uuid; +use function sprintf; +use function substr; + final readonly class ApiKeyMeta { /** @@ -14,7 +17,7 @@ final readonly class ApiKeyMeta */ private function __construct( public string $key, - public string|null $name, + public string $name, public Chronos|null $expirationDate, public iterable $roleDefinitions, ) { @@ -34,8 +37,19 @@ final readonly class ApiKeyMeta Chronos|null $expirationDate = null, iterable $roleDefinitions = [], ): self { + $resolvedKey = $key ?? Uuid::uuid4()->toString(); + + // If a name was not provided, fall back to the key + if (empty($name)) { + // If the key was auto-generated, fall back to a "censored" version of the UUID, otherwise simply use the + // plain key as fallback name + $name = $key === null + ? sprintf('%s-****-****-****-************', substr($resolvedKey, offset: 0, length: 8)) + : $key; + } + return new self( - key: $key ?? Uuid::uuid4()->toString(), + key: $resolvedKey, name: $name, expirationDate: $expirationDate, roleDefinitions: $roleDefinitions, diff --git a/module/Rest/src/ApiKey/Repository/ApiKeyRepository.php b/module/Rest/src/ApiKey/Repository/ApiKeyRepository.php index 2d82b23e..b4523371 100644 --- a/module/Rest/src/ApiKey/Repository/ApiKeyRepository.php +++ b/module/Rest/src/ApiKey/Repository/ApiKeyRepository.php @@ -15,31 +15,30 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class ApiKeyRepository extends EntitySpecificationRepository implements ApiKeyRepositoryInterface { /** - * Will create provided API key with admin permissions, only if there's no other API keys yet + * Will create provided API key with admin permissions, only if no other API keys exist yet */ public function createInitialApiKey(string $apiKey): ApiKey|null { $em = $this->getEntityManager(); return $em->wrapInTransaction(function () use ($apiKey, $em): ApiKey|null { - // Ideally this would be a SELECT COUNT(...), but MsSQL and Postgres do not allow locking on aggregates - // Because of that we check if at least one result exists - $firstResult = $em->createQueryBuilder()->select('a.id') - ->from(ApiKey::class, 'a') - ->setMaxResults(1) - ->getQuery() - ->setLockMode(LockMode::PESSIMISTIC_WRITE) - ->getOneOrNullResult(); + $firstResult = $em->createQueryBuilder() + ->select('a.id') + ->from(ApiKey::class, 'a') + ->setMaxResults(1) + ->getQuery() + ->setLockMode(LockMode::PESSIMISTIC_WRITE) + ->getOneOrNullResult(); // Do not create an initial API key if other keys already exist if ($firstResult !== null) { return null; } - $new = ApiKey::fromMeta(ApiKeyMeta::fromParams(key: $apiKey)); - $em->persist($new); + $initialApiKey = ApiKey::fromMeta(ApiKeyMeta::fromParams(key: $apiKey)); + $em->persist($initialApiKey); $em->flush(); - return $new; + return $initialApiKey; }); } } diff --git a/module/Rest/src/Entity/ApiKey.php b/module/Rest/src/Entity/ApiKey.php index 4f7575c5..8a72b85e 100644 --- a/module/Rest/src/Entity/ApiKey.php +++ b/module/Rest/src/Entity/ApiKey.php @@ -15,6 +15,8 @@ use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; use Shlinkio\Shlink\Rest\ApiKey\Role; +use function hash; + class ApiKey extends AbstractEntity { /** @@ -42,6 +44,7 @@ class ApiKey extends AbstractEntity */ public static function fromMeta(ApiKeyMeta $meta): self { +// $apiKey = new self(self::hashKey($meta->key), $meta->name, $meta->expirationDate); $apiKey = new self($meta->key, $meta->name, $meta->expirationDate); foreach ($meta->roleDefinitions as $roleDefinition) { $apiKey->registerRole($roleDefinition); @@ -50,6 +53,14 @@ class ApiKey extends AbstractEntity return $apiKey; } + /** + * Generates a hash for provided key, in the way Shlink expects API keys to be hashed + */ + public static function hashKey(string $key): string + { + return hash('sha256', $key); + } + public function isExpired(): bool { return $this->expirationDate !== null && $this->expirationDate->lessThan(Chronos::now()); diff --git a/module/Rest/src/Service/ApiKeyCheckResult.php b/module/Rest/src/Service/ApiKeyCheckResult.php index 4a1fc1cf..7c415097 100644 --- a/module/Rest/src/Service/ApiKeyCheckResult.php +++ b/module/Rest/src/Service/ApiKeyCheckResult.php @@ -6,9 +6,9 @@ namespace Shlinkio\Shlink\Rest\Service; use Shlinkio\Shlink\Rest\Entity\ApiKey; -final class ApiKeyCheckResult +final readonly class ApiKeyCheckResult { - public function __construct(public readonly ApiKey|null $apiKey = null) + public function __construct(public ApiKey|null $apiKey = null) { } diff --git a/module/Rest/src/Service/ApiKeyService.php b/module/Rest/src/Service/ApiKeyService.php index 6c825a4a..9b731a55 100644 --- a/module/Rest/src/Service/ApiKeyService.php +++ b/module/Rest/src/Service/ApiKeyService.php @@ -10,11 +10,9 @@ use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\ApiKey\Repository\ApiKeyRepositoryInterface; use Shlinkio\Shlink\Rest\Entity\ApiKey; -use function sprintf; - -class ApiKeyService implements ApiKeyServiceInterface +readonly class ApiKeyService implements ApiKeyServiceInterface { - public function __construct(private readonly EntityManagerInterface $em) + public function __construct(private EntityManagerInterface $em) { } @@ -48,11 +46,12 @@ class ApiKeyService implements ApiKeyServiceInterface { $apiKey = $this->getByKey($key); if ($apiKey === null) { - throw new InvalidArgumentException(sprintf('API key "%s" does not exist and can\'t be disabled', $key)); + throw new InvalidArgumentException('Provided API key does not exist and can\'t be disabled'); } $apiKey->disable(); $this->em->flush(); + return $apiKey; } @@ -62,17 +61,14 @@ class ApiKeyService implements ApiKeyServiceInterface public function listKeys(bool $enabledOnly = false): array { $conditions = $enabledOnly ? ['enabled' => true] : []; - /** @var ApiKey[] $apiKeys */ - $apiKeys = $this->em->getRepository(ApiKey::class)->findBy($conditions); - return $apiKeys; + return $this->em->getRepository(ApiKey::class)->findBy($conditions); } private function getByKey(string $key): ApiKey|null { - /** @var ApiKey|null $apiKey */ - $apiKey = $this->em->getRepository(ApiKey::class)->findOneBy([ + return $this->em->getRepository(ApiKey::class)->findOneBy([ +// 'key' => ApiKey::hashKey($key), 'key' => $key, ]); - return $apiKey; } } diff --git a/module/Rest/test/Service/ApiKeyServiceTest.php b/module/Rest/test/Service/ApiKeyServiceTest.php index a799da27..d6f49fb6 100644 --- a/module/Rest/test/Service/ApiKeyServiceTest.php +++ b/module/Rest/test/Service/ApiKeyServiceTest.php @@ -18,6 +18,8 @@ use Shlinkio\Shlink\Rest\ApiKey\Repository\ApiKeyRepository; use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Service\ApiKeyService; +use function substr; + class ApiKeyServiceTest extends TestCase { private ApiKeyService $service; @@ -40,12 +42,14 @@ class ApiKeyServiceTest extends TestCase $this->em->expects($this->once())->method('flush'); $this->em->expects($this->once())->method('persist')->with($this->isInstanceOf(ApiKey::class)); - $key = $this->service->create( - ApiKeyMeta::fromParams(name: $name, expirationDate: $date, roleDefinitions: $roles), - ); + $meta = ApiKeyMeta::fromParams(name: $name, expirationDate: $date, roleDefinitions: $roles); + $key = $this->service->create($meta); self::assertEquals($date, $key->expirationDate); - self::assertEquals($name, $key->name); + self::assertEquals( + empty($name) ? substr($meta->key, 0, 8) . '-****-****-****-************' : $name, + $key->name, + ); foreach ($roles as $roleDefinition) { self::assertTrue($key->hasRole($roleDefinition->role)); } From 9f6975119eb0a947e51ef8e8526d723c1b632db5 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 5 Nov 2024 22:52:01 +0100 Subject: [PATCH 22/80] Show only API key name in short URLs list --- .../Command/ShortUrl/ListShortUrlsCommand.php | 13 +- .../ShortUrl/ListShortUrlsCommandTest.php | 119 +++++++++--------- module/Core/src/ShortUrl/Entity/ShortUrl.php | 5 + 3 files changed, 69 insertions(+), 68 deletions(-) diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index 900402e1..fadc78e2 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -118,14 +118,9 @@ class ListShortUrlsCommand extends Command 'show-api-key', 'k', InputOption::VALUE_NONE, - 'Whether to display the API key from which the URL was generated or not.', - ) - ->addOption( - 'show-api-key-name', - 'm', - InputOption::VALUE_NONE, 'Whether to display the API key name from which the URL was generated or not.', ) + ->addOption('show-api-key-name', 'm', InputOption::VALUE_NONE, '[DEPRECATED] Use show-api-key') ->addOption( 'all', 'a', @@ -242,11 +237,7 @@ class ListShortUrlsCommand extends Command $columnsMap['Domain'] = static fn (array $_, ShortUrl $shortUrl): string => $shortUrl->getDomain()?->authority ?? Domain::DEFAULT_AUTHORITY; } - if ($input->getOption('show-api-key')) { - $columnsMap['API Key'] = static fn (array $_, ShortUrl $shortUrl): string => - $shortUrl->authorApiKey?->key ?? ''; - } - if ($input->getOption('show-api-key-name')) { + if ($input->getOption('show-api-key') || $input->getOption('show-api-key-name')) { $columnsMap['API Key Name'] = static fn (array $_, ShortUrl $shortUrl): string|null => $shortUrl->authorApiKey?->name; } diff --git a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php index 3b84d175..0a7f9aa0 100644 --- a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php @@ -25,7 +25,6 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; use ShlinkioTest\Shlink\CLI\Util\CliTestUtils; use Symfony\Component\Console\Tester\CommandTester; -use function count; use function explode; class ListShortUrlsCommandTest extends TestCase @@ -105,94 +104,100 @@ class ListShortUrlsCommandTest extends TestCase #[Test, DataProvider('provideOptionalFlags')] public function provideOptionalFlagsMakesNewColumnsToBeIncluded( array $input, - array $expectedContents, - array $notExpectedContents, - ApiKey $apiKey, + string $expectedOutput, + ShortUrl $shortUrl, ): void { $this->shortUrlService->expects($this->once())->method('listShortUrls')->with( ShortUrlsParams::empty(), )->willReturn(new Paginator(new ArrayAdapter([ - ShortUrlWithDeps::fromShortUrl( - ShortUrl::create(ShortUrlCreation::fromRawData([ - 'longUrl' => 'https://foo.com', - 'tags' => ['foo', 'bar', 'baz'], - 'apiKey' => $apiKey, - ])), - ), + ShortUrlWithDeps::fromShortUrl($shortUrl), ]))); $this->commandTester->setInputs(['y']); $this->commandTester->execute($input); $output = $this->commandTester->getDisplay(); - if (count($expectedContents) === 0 && count($notExpectedContents) === 0) { - self::fail('No expectations were run'); - } - - foreach ($expectedContents as $column) { - self::assertStringContainsString($column, $output); - } - foreach ($notExpectedContents as $column) { - self::assertStringNotContainsString($column, $output); - } + self::assertStringContainsString($expectedOutput, $output); } public static function provideOptionalFlags(): iterable { - $apiKey = ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'my api key')); - $key = $apiKey->key; + $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([ + 'longUrl' => 'https://foo.com', + 'tags' => ['foo', 'bar', 'baz'], + 'apiKey' => ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'my api key')), + ])); + $shortCode = $shortUrl->getShortCode(); + $created = $shortUrl->dateCreated()->toAtomString(); + // phpcs:disable Generic.Files.LineLength yield 'tags only' => [ ['--show-tags' => true], - ['| Tags ', '| foo, bar, baz'], - ['| API Key ', '| API Key Name |', $key, '| my api key', '| Domain', '| DEFAULT'], - $apiKey, + << [ ['--show-domain' => true], - ['| Domain', '| DEFAULT'], - ['| Tags ', '| foo, bar, baz', '| API Key ', '| API Key Name |', $key, '| my api key'], - $apiKey, + << [ ['--show-api-key' => true], - ['| API Key ', $key], - ['| Tags ', '| foo, bar, baz', '| API Key Name |', '| my api key', '| Domain', '| DEFAULT'], - $apiKey, - ]; - yield 'api key name only' => [ - ['--show-api-key-name' => true], - ['| API Key Name |', '| my api key'], - ['| Tags ', '| foo, bar, baz', '| API Key ', $key], - $apiKey, + << [ ['--show-tags' => true, '--show-api-key' => true], - ['| API Key ', '| Tags ', '| foo, bar, baz', $key], - ['| API Key Name |', '| my api key'], - $apiKey, + << [ ['--show-tags' => true, '--show-domain' => true], - ['| Tags ', '| foo, bar, baz', '| Domain', '| DEFAULT'], - ['| API Key Name |', '| my api key'], - $apiKey, + << [ - ['--show-tags' => true, '--show-domain' => true, '--show-api-key' => true, '--show-api-key-name' => true], - [ - '| API Key ', - '| Tags ', - '| API Key Name |', - '| foo, bar, baz', - $key, - '| my api key', - '| Domain', - '| DEFAULT', - ], - [], - $apiKey, + ['--show-tags' => true, '--show-domain' => true, '--show-api-key' => true], + <<title; } + public function dateCreated(): Chronos + { + return $this->dateCreated; + } + public function reachedVisits(int $visitsAmount): bool { return count($this->visits) >= $visitsAmount; From 1b9c8377ae6d9c61fd4a488f3c3d822cf37e47bd Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 5 Nov 2024 23:23:06 +0100 Subject: [PATCH 23/80] Hash existing API keys, and do checks against the hash --- .../Core/migrations/Version20241105215309.php | 45 +++++++++++++++++++ module/Rest/src/Entity/ApiKey.php | 3 +- module/Rest/src/Service/ApiKeyService.php | 3 +- .../Repository/ApiKeyRepositoryTest.php | 4 +- .../Rest/test/Service/ApiKeyServiceTest.php | 16 +++++-- 5 files changed, 61 insertions(+), 10 deletions(-) create mode 100644 module/Core/migrations/Version20241105215309.php diff --git a/module/Core/migrations/Version20241105215309.php b/module/Core/migrations/Version20241105215309.php new file mode 100644 index 00000000..0e9f7eff --- /dev/null +++ b/module/Core/migrations/Version20241105215309.php @@ -0,0 +1,45 @@ +connection->quoteIdentifier('key'); + + $qb = $this->connection->createQueryBuilder(); + $qb->select($keyColumnName) + ->from('api_keys'); + $result = $qb->executeQuery(); + + $updateQb = $this->connection->createQueryBuilder(); + $updateQb + ->update('api_keys') + ->set($keyColumnName, ':encryptedKey') + ->where($updateQb->expr()->eq($keyColumnName, ':plainTextKey')); + + while ($key = $result->fetchOne()) { + $updateQb->setParameters([ + 'encryptedKey' => hash('sha256', $key), + 'plainTextKey' => $key, + ])->executeStatement(); + } + } + + public function isTransactional(): bool + { + return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); + } +} diff --git a/module/Rest/src/Entity/ApiKey.php b/module/Rest/src/Entity/ApiKey.php index 8a72b85e..32f8fff4 100644 --- a/module/Rest/src/Entity/ApiKey.php +++ b/module/Rest/src/Entity/ApiKey.php @@ -44,8 +44,7 @@ class ApiKey extends AbstractEntity */ public static function fromMeta(ApiKeyMeta $meta): self { -// $apiKey = new self(self::hashKey($meta->key), $meta->name, $meta->expirationDate); - $apiKey = new self($meta->key, $meta->name, $meta->expirationDate); + $apiKey = new self(self::hashKey($meta->key), $meta->name, $meta->expirationDate); foreach ($meta->roleDefinitions as $roleDefinition) { $apiKey->registerRole($roleDefinition); } diff --git a/module/Rest/src/Service/ApiKeyService.php b/module/Rest/src/Service/ApiKeyService.php index 9b731a55..ca43a81f 100644 --- a/module/Rest/src/Service/ApiKeyService.php +++ b/module/Rest/src/Service/ApiKeyService.php @@ -67,8 +67,7 @@ readonly class ApiKeyService implements ApiKeyServiceInterface private function getByKey(string $key): ApiKey|null { return $this->em->getRepository(ApiKey::class)->findOneBy([ -// 'key' => ApiKey::hashKey($key), - 'key' => $key, + 'key' => ApiKey::hashKey($key), ]); } } diff --git a/module/Rest/test-db/ApiKey/Repository/ApiKeyRepositoryTest.php b/module/Rest/test-db/ApiKey/Repository/ApiKeyRepositoryTest.php index c19d8512..62d52de6 100644 --- a/module/Rest/test-db/ApiKey/Repository/ApiKeyRepositoryTest.php +++ b/module/Rest/test-db/ApiKey/Repository/ApiKeyRepositoryTest.php @@ -24,9 +24,9 @@ class ApiKeyRepositoryTest extends DatabaseTestCase self::assertCount(0, $this->repo->findAll()); self::assertNotNull($this->repo->createInitialApiKey('initial_value')); self::assertCount(1, $this->repo->findAll()); - self::assertCount(1, $this->repo->findBy(['key' => 'initial_value'])); + self::assertCount(1, $this->repo->findBy(['key' => ApiKey::hashKey('initial_value')])); self::assertNull($this->repo->createInitialApiKey('another_one')); self::assertCount(1, $this->repo->findAll()); - self::assertCount(0, $this->repo->findBy(['key' => 'another_one'])); + self::assertCount(0, $this->repo->findBy(['key' => ApiKey::hashKey('another_one')])); } } diff --git a/module/Rest/test/Service/ApiKeyServiceTest.php b/module/Rest/test/Service/ApiKeyServiceTest.php index d6f49fb6..b081b99a 100644 --- a/module/Rest/test/Service/ApiKeyServiceTest.php +++ b/module/Rest/test/Service/ApiKeyServiceTest.php @@ -74,7 +74,9 @@ class ApiKeyServiceTest extends TestCase #[Test, DataProvider('provideInvalidApiKeys')] public function checkReturnsFalseForInvalidApiKeys(ApiKey|null $invalidKey): void { - $this->repo->expects($this->once())->method('findOneBy')->with(['key' => '12345'])->willReturn($invalidKey); + $this->repo->expects($this->once())->method('findOneBy')->with(['key' => ApiKey::hashKey('12345')])->willReturn( + $invalidKey, + ); $this->em->method('getRepository')->with(ApiKey::class)->willReturn($this->repo); $result = $this->service->check('12345'); @@ -97,7 +99,9 @@ class ApiKeyServiceTest extends TestCase { $apiKey = ApiKey::create(); - $this->repo->expects($this->once())->method('findOneBy')->with(['key' => '12345'])->willReturn($apiKey); + $this->repo->expects($this->once())->method('findOneBy')->with(['key' => ApiKey::hashKey('12345')])->willReturn( + $apiKey, + ); $this->em->method('getRepository')->with(ApiKey::class)->willReturn($this->repo); $result = $this->service->check('12345'); @@ -109,7 +113,9 @@ class ApiKeyServiceTest extends TestCase #[Test] public function disableThrowsExceptionWhenNoApiKeyIsFound(): void { - $this->repo->expects($this->once())->method('findOneBy')->with(['key' => '12345'])->willReturn(null); + $this->repo->expects($this->once())->method('findOneBy')->with(['key' => ApiKey::hashKey('12345')])->willReturn( + null, + ); $this->em->method('getRepository')->with(ApiKey::class)->willReturn($this->repo); $this->expectException(InvalidArgumentException::class); @@ -121,7 +127,9 @@ class ApiKeyServiceTest extends TestCase public function disableReturnsDisabledApiKeyWhenFound(): void { $key = ApiKey::create(); - $this->repo->expects($this->once())->method('findOneBy')->with(['key' => '12345'])->willReturn($key); + $this->repo->expects($this->once())->method('findOneBy')->with(['key' => ApiKey::hashKey('12345')])->willReturn( + $key, + ); $this->em->method('getRepository')->with(ApiKey::class)->willReturn($this->repo); $this->em->expects($this->once())->method('flush'); From f6d70c599e6b6be45c0756e0b84692436d81b203 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 5 Nov 2024 23:31:10 +0100 Subject: [PATCH 24/80] Make name required in ApiKey entity --- module/Rest/src/ApiKey/Model/ApiKeyMeta.php | 2 +- module/Rest/src/Entity/ApiKey.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/module/Rest/src/ApiKey/Model/ApiKeyMeta.php b/module/Rest/src/ApiKey/Model/ApiKeyMeta.php index 21efe16a..66d7d889 100644 --- a/module/Rest/src/ApiKey/Model/ApiKeyMeta.php +++ b/module/Rest/src/ApiKey/Model/ApiKeyMeta.php @@ -41,7 +41,7 @@ final readonly class ApiKeyMeta // If a name was not provided, fall back to the key if (empty($name)) { - // If the key was auto-generated, fall back to a "censored" version of the UUID, otherwise simply use the + // If the key was auto-generated, fall back to a redacted version of the UUID, otherwise simply use the // plain key as fallback name $name = $key === null ? sprintf('%s-****-****-****-************', substr($resolvedKey, offset: 0, length: 8)) diff --git a/module/Rest/src/Entity/ApiKey.php b/module/Rest/src/Entity/ApiKey.php index 32f8fff4..17461d12 100644 --- a/module/Rest/src/Entity/ApiKey.php +++ b/module/Rest/src/Entity/ApiKey.php @@ -24,7 +24,7 @@ class ApiKey extends AbstractEntity */ private function __construct( public readonly string $key, - public readonly string|null $name = null, + public readonly string $name, public readonly Chronos|null $expirationDate = null, private bool $enabled = true, private Collection $roles = new ArrayCollection(), From bd73362c94a204eca1aa0d594021ffd7885329be Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 6 Nov 2024 20:10:06 +0100 Subject: [PATCH 25/80] Update api-key:disable command to allow passing a name --- .../CLI/src/Command/Api/DisableKeyCommand.php | 74 +++++++++++++-- .../Command/Api/DisableKeyCommandTest.php | 90 +++++++++++++++++-- module/Rest/src/Entity/ApiKey.php | 7 -- module/Rest/src/Service/ApiKeyService.php | 20 ++++- .../src/Service/ApiKeyServiceInterface.php | 8 +- .../Rest/test/Service/ApiKeyServiceTest.php | 26 +++--- 6 files changed, 188 insertions(+), 37 deletions(-) diff --git a/module/CLI/src/Command/Api/DisableKeyCommand.php b/module/CLI/src/Command/Api/DisableKeyCommand.php index 3da85e9e..c2ed4173 100644 --- a/module/CLI/src/Command/Api/DisableKeyCommand.php +++ b/module/CLI/src/Command/Api/DisableKeyCommand.php @@ -6,39 +6,99 @@ namespace Shlinkio\Shlink\CLI\Command\Api; use Shlinkio\Shlink\CLI\Util\ExitCode; use Shlinkio\Shlink\Common\Exception\InvalidArgumentException; +use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +use function Shlinkio\Shlink\Core\ArrayUtils\map; use function sprintf; class DisableKeyCommand extends Command { public const NAME = 'api-key:disable'; - public function __construct(private ApiKeyServiceInterface $apiKeyService) + public function __construct(private readonly ApiKeyServiceInterface $apiKeyService) { parent::__construct(); } protected function configure(): void { - $this->setName(self::NAME) - ->setDescription('Disables an API key.') - ->addArgument('apiKey', InputArgument::REQUIRED, 'The API key to disable'); + $help = <<%command.name% command allows you to disable an existing API key, via its name or the + plain-text key. + + If no arguments are provided, you will be prompted to select one of the existing non-disabled API keys. + + %command.full_name% + + You can optionally pass the API key name to be disabled. In that case --by-name is also + required, to indicate the first argument is the API key name and not the plain-text key: + + %command.full_name% the_key_name --by-name + + You can pass the plain-text key to be disabled, but that is DEPRECATED. In next major version, + the argument will always be assumed to be the name: + + %command.full_name% d6b6c60e-edcd-4e43-96ad-fa6b7014c143 + + HELP; + + $this + ->setName(self::NAME) + ->setDescription('Disables an API key by name or plain-text key (providing a plain-text key is DEPRECATED)') + ->addArgument( + 'keyOrName', + InputArgument::OPTIONAL, + 'The API key to disable. Pass `--by-name` to indicate this value is the name and not the key.', + ) + ->addOption( + 'by-name', + mode: InputOption::VALUE_NONE, + description: 'Indicates the first argument is the API key name, not the plain-text key.', + ) + ->setHelp($help); + } + + protected function interact(InputInterface $input, OutputInterface $output): void + { + $keyOrName = $input->getArgument('keyOrName'); + + if ($keyOrName === null) { + $apiKeys = $this->apiKeyService->listKeys(enabledOnly: true); + $name = (new SymfonyStyle($input, $output))->choice( + 'What API key do you want to disable?', + map($apiKeys, static fn (ApiKey $apiKey) => $apiKey->name), + ); + + $input->setArgument('keyOrName', $name); + $input->setOption('by-name', true); + } } protected function execute(InputInterface $input, OutputInterface $output): int { - $apiKey = $input->getArgument('apiKey'); + $keyOrName = $input->getArgument('keyOrName'); + $byName = $input->getOption('by-name'); $io = new SymfonyStyle($input, $output); + if (! $keyOrName) { + $io->warning('An API key name was not provided.'); + return ExitCode::EXIT_WARNING; + } + try { - $this->apiKeyService->disable($apiKey); - $io->success(sprintf('API key "%s" properly disabled', $apiKey)); + if ($byName) { + $this->apiKeyService->disableByName($keyOrName); + } else { + $this->apiKeyService->disableByKey($keyOrName); + } + $io->success(sprintf('API key "%s" properly disabled', $keyOrName)); return ExitCode::EXIT_SUCCESS; } catch (InvalidArgumentException $e) { $io->error($e->getMessage()); diff --git a/module/CLI/test/Command/Api/DisableKeyCommandTest.php b/module/CLI/test/Command/Api/DisableKeyCommandTest.php index a12cb46f..a617539d 100644 --- a/module/CLI/test/Command/Api/DisableKeyCommandTest.php +++ b/module/CLI/test/Command/Api/DisableKeyCommandTest.php @@ -8,7 +8,10 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\CLI\Command\Api\DisableKeyCommand; +use Shlinkio\Shlink\CLI\Util\ExitCode; use Shlinkio\Shlink\Common\Exception\InvalidArgumentException; +use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; +use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; use ShlinkioTest\Shlink\CLI\Util\CliTestUtils; use Symfony\Component\Console\Tester\CommandTester; @@ -28,30 +31,103 @@ class DisableKeyCommandTest extends TestCase public function providedApiKeyIsDisabled(): void { $apiKey = 'abcd1234'; - $this->apiKeyService->expects($this->once())->method('disable')->with($apiKey); + $this->apiKeyService->expects($this->once())->method('disableByKey')->with($apiKey); + $this->apiKeyService->expects($this->never())->method('disableByName'); - $this->commandTester->execute([ - 'apiKey' => $apiKey, + $exitCode = $this->commandTester->execute([ + 'keyOrName' => $apiKey, ]); $output = $this->commandTester->getDisplay(); self::assertStringContainsString('API key "abcd1234" properly disabled', $output); + self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode); } #[Test] - public function errorIsReturnedIfServiceThrowsException(): void + public function providedApiKeyIsDisabledByName(): void + { + $name = 'the key to delete'; + $this->apiKeyService->expects($this->once())->method('disableByName')->with($name); + $this->apiKeyService->expects($this->never())->method('disableByKey'); + + $exitCode = $this->commandTester->execute([ + 'keyOrName' => $name, + '--by-name' => true, + ]); + $output = $this->commandTester->getDisplay(); + + self::assertStringContainsString('API key "the key to delete" properly disabled', $output); + self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode); + } + + #[Test] + public function errorIsReturnedIfDisableByKeyThrowsException(): void { $apiKey = 'abcd1234'; $expectedMessage = 'API key "abcd1234" does not exist.'; - $this->apiKeyService->expects($this->once())->method('disable')->with($apiKey)->willThrowException( + $this->apiKeyService->expects($this->once())->method('disableByKey')->with($apiKey)->willThrowException( new InvalidArgumentException($expectedMessage), ); + $this->apiKeyService->expects($this->never())->method('disableByName'); - $this->commandTester->execute([ - 'apiKey' => $apiKey, + $exitCode = $this->commandTester->execute([ + 'keyOrName' => $apiKey, ]); $output = $this->commandTester->getDisplay(); self::assertStringContainsString($expectedMessage, $output); + self::assertEquals(ExitCode::EXIT_FAILURE, $exitCode); + } + + #[Test] + public function errorIsReturnedIfDisableByNameThrowsException(): void + { + $name = 'the key to delete'; + $expectedMessage = 'API key "the key to delete" does not exist.'; + $this->apiKeyService->expects($this->once())->method('disableByName')->with($name)->willThrowException( + new InvalidArgumentException($expectedMessage), + ); + $this->apiKeyService->expects($this->never())->method('disableByKey'); + + $exitCode = $this->commandTester->execute([ + 'keyOrName' => $name, + '--by-name' => true, + ]); + $output = $this->commandTester->getDisplay(); + + self::assertStringContainsString($expectedMessage, $output); + self::assertEquals(ExitCode::EXIT_FAILURE, $exitCode); + } + + #[Test] + public function warningIsReturnedIfNoArgumentIsProvidedInNonInteractiveMode(): void + { + $this->apiKeyService->expects($this->never())->method('disableByName'); + $this->apiKeyService->expects($this->never())->method('disableByKey'); + $this->apiKeyService->expects($this->never())->method('listKeys'); + + $exitCode = $this->commandTester->execute([], ['interactive' => false]); + + self::assertEquals(ExitCode::EXIT_WARNING, $exitCode); + } + + #[Test] + public function existingApiKeyNamesAreListedIfNoArgumentIsProvidedInInteractiveMode(): void + { + $name = 'the key to delete'; + $this->apiKeyService->expects($this->once())->method('disableByName')->with($name); + $this->apiKeyService->expects($this->once())->method('listKeys')->willReturn([ + ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'foo')), + ApiKey::fromMeta(ApiKeyMeta::fromParams(name: $name)), + ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'bar')), + ]); + $this->apiKeyService->expects($this->never())->method('disableByKey'); + + $this->commandTester->setInputs([$name]); + $exitCode = $this->commandTester->execute([]); + $output = $this->commandTester->getDisplay(); + + self::assertStringContainsString('API key "the key to delete" properly disabled', $output); + self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode); } } diff --git a/module/Rest/src/Entity/ApiKey.php b/module/Rest/src/Entity/ApiKey.php index 17461d12..fea06a1d 100644 --- a/module/Rest/src/Entity/ApiKey.php +++ b/module/Rest/src/Entity/ApiKey.php @@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\Rest\Entity; use Cake\Chronos\Chronos; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; -use Exception; use Happyr\DoctrineSpecification\Spec; use Happyr\DoctrineSpecification\Specification\Specification; use Shlinkio\Shlink\Common\Entity\AbstractEntity; @@ -31,17 +30,11 @@ class ApiKey extends AbstractEntity ) { } - /** - * @throws Exception - */ public static function create(): ApiKey { return self::fromMeta(ApiKeyMeta::empty()); } - /** - * @throws Exception - */ public static function fromMeta(ApiKeyMeta $meta): self { $apiKey = new self(self::hashKey($meta->key), $meta->name, $meta->expirationDate); diff --git a/module/Rest/src/Service/ApiKeyService.php b/module/Rest/src/Service/ApiKeyService.php index ca43a81f..66cb1b18 100644 --- a/module/Rest/src/Service/ApiKeyService.php +++ b/module/Rest/src/Service/ApiKeyService.php @@ -40,11 +40,25 @@ readonly class ApiKeyService implements ApiKeyServiceInterface } /** - * @throws InvalidArgumentException + * @inheritDoc */ - public function disable(string $key): ApiKey + public function disableByName(string $apiKeyName): ApiKey + { + return $this->disableApiKey($this->em->getRepository(ApiKey::class)->findOneBy([ + 'name' => $apiKeyName, + ])); + } + + /** + * @inheritDoc + */ + public function disableByKey(string $key): ApiKey + { + return $this->disableApiKey($this->getByKey($key)); + } + + private function disableApiKey(ApiKey|null $apiKey): ApiKey { - $apiKey = $this->getByKey($key); if ($apiKey === null) { throw new InvalidArgumentException('Provided API key does not exist and can\'t be disabled'); } diff --git a/module/Rest/src/Service/ApiKeyServiceInterface.php b/module/Rest/src/Service/ApiKeyServiceInterface.php index 167041c5..1fefc5f4 100644 --- a/module/Rest/src/Service/ApiKeyServiceInterface.php +++ b/module/Rest/src/Service/ApiKeyServiceInterface.php @@ -19,7 +19,13 @@ interface ApiKeyServiceInterface /** * @throws InvalidArgumentException */ - public function disable(string $key): ApiKey; + public function disableByName(string $apiKeyName): ApiKey; + + /** + * @deprecated Use `self::disableByName($name)` instead + * @throws InvalidArgumentException + */ + public function disableByKey(string $key): ApiKey; /** * @return ApiKey[] diff --git a/module/Rest/test/Service/ApiKeyServiceTest.php b/module/Rest/test/Service/ApiKeyServiceTest.php index b081b99a..ba57fec4 100644 --- a/module/Rest/test/Service/ApiKeyServiceTest.php +++ b/module/Rest/test/Service/ApiKeyServiceTest.php @@ -110,35 +110,37 @@ class ApiKeyServiceTest extends TestCase self::assertSame($apiKey, $result->apiKey); } - #[Test] - public function disableThrowsExceptionWhenNoApiKeyIsFound(): void + #[Test, DataProvider('provideDisableArgs')] + public function disableThrowsExceptionWhenNoApiKeyIsFound(string $disableMethod, array $findOneByArg): void { - $this->repo->expects($this->once())->method('findOneBy')->with(['key' => ApiKey::hashKey('12345')])->willReturn( - null, - ); + $this->repo->expects($this->once())->method('findOneBy')->with($findOneByArg)->willReturn(null); $this->em->method('getRepository')->with(ApiKey::class)->willReturn($this->repo); $this->expectException(InvalidArgumentException::class); - $this->service->disable('12345'); + $this->service->{$disableMethod}('12345'); } - #[Test] - public function disableReturnsDisabledApiKeyWhenFound(): void + #[Test, DataProvider('provideDisableArgs')] + public function disableReturnsDisabledApiKeyWhenFound(string $disableMethod, array $findOneByArg): void { $key = ApiKey::create(); - $this->repo->expects($this->once())->method('findOneBy')->with(['key' => ApiKey::hashKey('12345')])->willReturn( - $key, - ); + $this->repo->expects($this->once())->method('findOneBy')->with($findOneByArg)->willReturn($key); $this->em->method('getRepository')->with(ApiKey::class)->willReturn($this->repo); $this->em->expects($this->once())->method('flush'); self::assertTrue($key->isEnabled()); - $returnedKey = $this->service->disable('12345'); + $returnedKey = $this->service->{$disableMethod}('12345'); self::assertFalse($key->isEnabled()); self::assertSame($key, $returnedKey); } + public static function provideDisableArgs(): iterable + { + yield 'disableByKey' => ['disableByKey', ['key' => ApiKey::hashKey('12345')]]; + yield 'disableByName' => ['disableByName', ['name' => '12345']]; + } + #[Test] public function listFindsAllApiKeys(): void { From 6f95acc2024bdc07afedd4650a7c83b9d69b3001 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 7 Nov 2024 09:34:42 +0100 Subject: [PATCH 26/80] Inject ApiKeyRepository in ApiKeyService --- module/Rest/config/dependencies.config.php | 5 +++- module/Rest/src/Service/ApiKeyService.php | 27 +++++++++---------- .../Rest/test/Service/ApiKeyServiceTest.php | 15 +++-------- 3 files changed, 21 insertions(+), 26 deletions(-) diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index b69cf36d..df482a46 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -9,6 +9,7 @@ use Laminas\ServiceManager\Factory\InvokableFactory; use Mezzio\ProblemDetails\ProblemDetailsResponseFactory; use Mezzio\Router\Middleware\ImplicitOptionsMiddleware; use Psr\Log\LoggerInterface; +use Shlinkio\Shlink\Common\Doctrine\EntityRepositoryFactory; use Shlinkio\Shlink\Common\Mercure\LcobucciJwtProvider; use Shlinkio\Shlink\Core\Config; use Shlinkio\Shlink\Core\Domain\DomainService; @@ -17,6 +18,7 @@ use Shlinkio\Shlink\Core\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer; use Shlinkio\Shlink\Core\Tag\TagService; use Shlinkio\Shlink\Core\Visit; +use Shlinkio\Shlink\Rest\ApiKey\Repository\ApiKeyRepository; use Shlinkio\Shlink\Rest\Service\ApiKeyService; return [ @@ -24,6 +26,7 @@ return [ 'dependencies' => [ 'factories' => [ ApiKeyService::class => ConfigAbstractFactory::class, + ApiKeyRepository::class => [EntityRepositoryFactory::class, Entity\ApiKey::class], Action\HealthAction::class => ConfigAbstractFactory::class, Action\MercureInfoAction::class => ConfigAbstractFactory::class, @@ -62,7 +65,7 @@ return [ ], ConfigAbstractFactory::class => [ - ApiKeyService::class => ['em'], + ApiKeyService::class => ['em', ApiKeyRepository::class], Action\HealthAction::class => ['em', Config\Options\AppOptions::class], Action\MercureInfoAction::class => [LcobucciJwtProvider::class, 'config.mercure'], diff --git a/module/Rest/src/Service/ApiKeyService.php b/module/Rest/src/Service/ApiKeyService.php index 66cb1b18..e1a2f57a 100644 --- a/module/Rest/src/Service/ApiKeyService.php +++ b/module/Rest/src/Service/ApiKeyService.php @@ -12,7 +12,7 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; readonly class ApiKeyService implements ApiKeyServiceInterface { - public function __construct(private EntityManagerInterface $em) + public function __construct(private EntityManagerInterface $em, private ApiKeyRepositoryInterface $repo) { } @@ -28,14 +28,12 @@ readonly class ApiKeyService implements ApiKeyServiceInterface public function createInitial(string $key): ApiKey|null { - /** @var ApiKeyRepositoryInterface $repo */ - $repo = $this->em->getRepository(ApiKey::class); - return $repo->createInitialApiKey($key); + return $this->repo->createInitialApiKey($key); } public function check(string $key): ApiKeyCheckResult { - $apiKey = $this->getByKey($key); + $apiKey = $this->findByKey($key); return new ApiKeyCheckResult($apiKey); } @@ -44,9 +42,7 @@ readonly class ApiKeyService implements ApiKeyServiceInterface */ public function disableByName(string $apiKeyName): ApiKey { - return $this->disableApiKey($this->em->getRepository(ApiKey::class)->findOneBy([ - 'name' => $apiKeyName, - ])); + return $this->disableApiKey($this->findByName($apiKeyName)); } /** @@ -54,7 +50,7 @@ readonly class ApiKeyService implements ApiKeyServiceInterface */ public function disableByKey(string $key): ApiKey { - return $this->disableApiKey($this->getByKey($key)); + return $this->disableApiKey($this->findByKey($key)); } private function disableApiKey(ApiKey|null $apiKey): ApiKey @@ -75,13 +71,16 @@ readonly class ApiKeyService implements ApiKeyServiceInterface public function listKeys(bool $enabledOnly = false): array { $conditions = $enabledOnly ? ['enabled' => true] : []; - return $this->em->getRepository(ApiKey::class)->findBy($conditions); + return $this->repo->findBy($conditions); } - private function getByKey(string $key): ApiKey|null + private function findByKey(string $key): ApiKey|null { - return $this->em->getRepository(ApiKey::class)->findOneBy([ - 'key' => ApiKey::hashKey($key), - ]); + return $this->repo->findOneBy(['key' => ApiKey::hashKey($key)]); + } + + private function findByName(string $name): ApiKey|null + { + return $this->repo->findOneBy(['name' => $name]); } } diff --git a/module/Rest/test/Service/ApiKeyServiceTest.php b/module/Rest/test/Service/ApiKeyServiceTest.php index ba57fec4..d3c62af2 100644 --- a/module/Rest/test/Service/ApiKeyServiceTest.php +++ b/module/Rest/test/Service/ApiKeyServiceTest.php @@ -14,7 +14,7 @@ use Shlinkio\Shlink\Common\Exception\InvalidArgumentException; use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; -use Shlinkio\Shlink\Rest\ApiKey\Repository\ApiKeyRepository; +use Shlinkio\Shlink\Rest\ApiKey\Repository\ApiKeyRepositoryInterface; use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Service\ApiKeyService; @@ -24,13 +24,13 @@ class ApiKeyServiceTest extends TestCase { private ApiKeyService $service; private MockObject & EntityManager $em; - private MockObject & ApiKeyRepository $repo; + private MockObject & ApiKeyRepositoryInterface $repo; protected function setUp(): void { $this->em = $this->createMock(EntityManager::class); - $this->repo = $this->createMock(ApiKeyRepository::class); - $this->service = new ApiKeyService($this->em); + $this->repo = $this->createMock(ApiKeyRepositoryInterface::class); + $this->service = new ApiKeyService($this->em, $this->repo); } /** @@ -77,7 +77,6 @@ class ApiKeyServiceTest extends TestCase $this->repo->expects($this->once())->method('findOneBy')->with(['key' => ApiKey::hashKey('12345')])->willReturn( $invalidKey, ); - $this->em->method('getRepository')->with(ApiKey::class)->willReturn($this->repo); $result = $this->service->check('12345'); @@ -102,7 +101,6 @@ class ApiKeyServiceTest extends TestCase $this->repo->expects($this->once())->method('findOneBy')->with(['key' => ApiKey::hashKey('12345')])->willReturn( $apiKey, ); - $this->em->method('getRepository')->with(ApiKey::class)->willReturn($this->repo); $result = $this->service->check('12345'); @@ -114,7 +112,6 @@ class ApiKeyServiceTest extends TestCase public function disableThrowsExceptionWhenNoApiKeyIsFound(string $disableMethod, array $findOneByArg): void { $this->repo->expects($this->once())->method('findOneBy')->with($findOneByArg)->willReturn(null); - $this->em->method('getRepository')->with(ApiKey::class)->willReturn($this->repo); $this->expectException(InvalidArgumentException::class); @@ -126,7 +123,6 @@ class ApiKeyServiceTest extends TestCase { $key = ApiKey::create(); $this->repo->expects($this->once())->method('findOneBy')->with($findOneByArg)->willReturn($key); - $this->em->method('getRepository')->with(ApiKey::class)->willReturn($this->repo); $this->em->expects($this->once())->method('flush'); self::assertTrue($key->isEnabled()); @@ -147,7 +143,6 @@ class ApiKeyServiceTest extends TestCase $expectedApiKeys = [ApiKey::create(), ApiKey::create(), ApiKey::create()]; $this->repo->expects($this->once())->method('findBy')->with([])->willReturn($expectedApiKeys); - $this->em->method('getRepository')->with(ApiKey::class)->willReturn($this->repo); $result = $this->service->listKeys(); @@ -160,7 +155,6 @@ class ApiKeyServiceTest extends TestCase $expectedApiKeys = [ApiKey::create(), ApiKey::create(), ApiKey::create()]; $this->repo->expects($this->once())->method('findBy')->with(['enabled' => true])->willReturn($expectedApiKeys); - $this->em->method('getRepository')->with(ApiKey::class)->willReturn($this->repo); $result = $this->service->listKeys(enabledOnly: true); @@ -171,7 +165,6 @@ class ApiKeyServiceTest extends TestCase public function createInitialDelegatesToRepository(ApiKey|null $apiKey): void { $this->repo->expects($this->once())->method('createInitialApiKey')->with('the_key')->willReturn($apiKey); - $this->em->method('getRepository')->with(ApiKey::class)->willReturn($this->repo); $result = $this->service->createInitial('the_key'); From 4c1ff72438e66fb6a7a746000d526465b12e84c0 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 7 Nov 2024 09:55:06 +0100 Subject: [PATCH 27/80] Add method to check if an API exists for a given name --- .../Repository/EntityRepositoryInterface.php | 20 +++++++++++++++++++ .../Repository/ApiKeyRepositoryInterface.php | 6 +++--- module/Rest/src/Service/ApiKeyService.php | 9 ++++++--- .../src/Service/ApiKeyServiceInterface.php | 5 +++++ .../Rest/test/Service/ApiKeyServiceTest.php | 12 +++++++++++ 5 files changed, 46 insertions(+), 6 deletions(-) create mode 100644 module/Core/src/Repository/EntityRepositoryInterface.php diff --git a/module/Core/src/Repository/EntityRepositoryInterface.php b/module/Core/src/Repository/EntityRepositoryInterface.php new file mode 100644 index 00000000..c6693c44 --- /dev/null +++ b/module/Core/src/Repository/EntityRepositoryInterface.php @@ -0,0 +1,20 @@ + + */ +interface EntityRepositoryInterface extends ObjectRepository +{ + /** + * @todo This should be part of ObjectRepository, so adding here until that interface defines it. + * EntityRepository already implements the method, so classes extending it won't have to add anything. + */ + public function count(array $criteria = []): int; +} diff --git a/module/Rest/src/ApiKey/Repository/ApiKeyRepositoryInterface.php b/module/Rest/src/ApiKey/Repository/ApiKeyRepositoryInterface.php index 04e55519..0f81dc10 100644 --- a/module/Rest/src/ApiKey/Repository/ApiKeyRepositoryInterface.php +++ b/module/Rest/src/ApiKey/Repository/ApiKeyRepositoryInterface.php @@ -4,14 +4,14 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Rest\ApiKey\Repository; -use Doctrine\Persistence\ObjectRepository; use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepositoryInterface; +use Shlinkio\Shlink\Core\Repository\EntityRepositoryInterface; use Shlinkio\Shlink\Rest\Entity\ApiKey; /** - * @extends ObjectRepository + * @extends EntityRepositoryInterface */ -interface ApiKeyRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface +interface ApiKeyRepositoryInterface extends EntityRepositoryInterface, EntitySpecificationRepositoryInterface { /** * Will create provided API key only if there's no API keys yet diff --git a/module/Rest/src/Service/ApiKeyService.php b/module/Rest/src/Service/ApiKeyService.php index e1a2f57a..4b786c6d 100644 --- a/module/Rest/src/Service/ApiKeyService.php +++ b/module/Rest/src/Service/ApiKeyService.php @@ -42,7 +42,7 @@ readonly class ApiKeyService implements ApiKeyServiceInterface */ public function disableByName(string $apiKeyName): ApiKey { - return $this->disableApiKey($this->findByName($apiKeyName)); + return $this->disableApiKey($this->repo->findOneBy(['name' => $apiKeyName])); } /** @@ -79,8 +79,11 @@ readonly class ApiKeyService implements ApiKeyServiceInterface return $this->repo->findOneBy(['key' => ApiKey::hashKey($key)]); } - private function findByName(string $name): ApiKey|null + /** + * @inheritDoc + */ + public function existsWithName(string $apiKeyName): bool { - return $this->repo->findOneBy(['name' => $name]); + return $this->repo->count(['name' => $apiKeyName]) > 0; } } diff --git a/module/Rest/src/Service/ApiKeyServiceInterface.php b/module/Rest/src/Service/ApiKeyServiceInterface.php index 1fefc5f4..73773fba 100644 --- a/module/Rest/src/Service/ApiKeyServiceInterface.php +++ b/module/Rest/src/Service/ApiKeyServiceInterface.php @@ -31,4 +31,9 @@ interface ApiKeyServiceInterface * @return ApiKey[] */ public function listKeys(bool $enabledOnly = false): array; + + /** + * Check if an API key exists for provided name + */ + public function existsWithName(string $apiKeyName): bool; } diff --git a/module/Rest/test/Service/ApiKeyServiceTest.php b/module/Rest/test/Service/ApiKeyServiceTest.php index d3c62af2..e304a651 100644 --- a/module/Rest/test/Service/ApiKeyServiceTest.php +++ b/module/Rest/test/Service/ApiKeyServiceTest.php @@ -8,6 +8,7 @@ use Cake\Chronos\Chronos; use Doctrine\ORM\EntityManager; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Common\Exception\InvalidArgumentException; @@ -176,4 +177,15 @@ class ApiKeyServiceTest extends TestCase yield 'first api key' => [ApiKey::create()]; yield 'existing api keys' => [null]; } + + #[Test] + #[TestWith([0, false])] + #[TestWith([1, true])] + #[TestWith([27, true])] + public function existsWithNameCountsEntriesInRepository(int $count, bool $expected): void + { + $name = 'the_key'; + $this->repo->expects($this->once())->method('count')->with(['name' => $name])->willReturn($count); + self::assertEquals($this->service->existsWithName($name), $expected); + } } From 9e6f129de6fe066525083ef3e23e1095f54cc3b5 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 7 Nov 2024 14:52:06 +0100 Subject: [PATCH 28/80] Make sure a unique name is required by api-key:generate command --- .../src/Command/Api/GenerateKeyCommand.php | 15 ++++++++----- .../Command/Api/GenerateKeyCommandTest.php | 22 ++++++++++++++++++- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/module/CLI/src/Command/Api/GenerateKeyCommand.php b/module/CLI/src/Command/Api/GenerateKeyCommand.php index a6b8bad0..3a1432ac 100644 --- a/module/CLI/src/Command/Api/GenerateKeyCommand.php +++ b/module/CLI/src/Command/Api/GenerateKeyCommand.php @@ -100,16 +100,22 @@ class GenerateKeyCommand extends Command protected function execute(InputInterface $input, OutputInterface $output): int { + $io = new SymfonyStyle($input, $output); $expirationDate = $input->getOption('expiration-date'); - $apiKeyMeta = ApiKeyMeta::fromParams( name: $input->getOption('name'), expirationDate: isset($expirationDate) ? Chronos::parse($expirationDate) : null, roleDefinitions: $this->roleResolver->determineRoles($input), ); - $apiKey = $this->apiKeyService->create($apiKeyMeta); - $io = new SymfonyStyle($input, $output); + if ($this->apiKeyService->existsWithName($apiKeyMeta->name)) { + $io->warning( + sprintf('An API key with name "%s" already exists. Try with a different ome', $apiKeyMeta->name), + ); + return ExitCode::EXIT_WARNING; + } + + $apiKey = $this->apiKeyService->create($apiKeyMeta); $io->success(sprintf('Generated API key: "%s"', $apiKeyMeta->key)); if ($input->isInteractive()) { @@ -120,8 +126,7 @@ class GenerateKeyCommand extends Command ShlinkTable::default($io)->render( ['Role name', 'Role metadata'], $apiKey->mapRoles(fn (Role $role, array $meta) => [$role->value, arrayToString($meta, indentSize: 0)]), - null, - 'Roles', + headerTitle: 'Roles', ); } diff --git a/module/CLI/test/Command/Api/GenerateKeyCommandTest.php b/module/CLI/test/Command/Api/GenerateKeyCommandTest.php index 9c1d337e..10633b9a 100644 --- a/module/CLI/test/Command/Api/GenerateKeyCommandTest.php +++ b/module/CLI/test/Command/Api/GenerateKeyCommandTest.php @@ -10,6 +10,7 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\CLI\ApiKey\RoleResolverInterface; use Shlinkio\Shlink\CLI\Command\Api\GenerateKeyCommand; +use Shlinkio\Shlink\CLI\Util\ExitCode; use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; @@ -64,8 +65,27 @@ class GenerateKeyCommandTest extends TestCase $this->callback(fn (ApiKeyMeta $meta) => $meta->name === 'Alice'), )->willReturn(ApiKey::create()); - $this->commandTester->execute([ + $exitCode = $this->commandTester->execute([ '--name' => 'Alice', ]); + + self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode); + } + + #[Test] + public function warningIsPrintedIfProvidedNameAlreadyExists(): void + { + $name = 'The API key'; + + $this->apiKeyService->expects($this->never())->method('create'); + $this->apiKeyService->expects($this->once())->method('existsWithName')->with($name)->willReturn(true); + + $exitCode = $this->commandTester->execute([ + '--name' => $name, + ]); + $output = $this->commandTester->getDisplay(); + + self::assertEquals(ExitCode::EXIT_WARNING, $exitCode); + self::assertStringContainsString('An API key with name "The API key" already exists.', $output); } } From a661d051000d2087154f2875098290923e90787e Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 8 Nov 2024 08:25:07 +0100 Subject: [PATCH 29/80] Allow API keys to be renamed --- .../CLI/src/Command/Tag/RenameTagCommand.php | 4 +- .../test/Command/Tag/RenameTagCommandTest.php | 6 +- .../src/Exception/TagConflictException.php | 4 +- .../TagRenaming.php => Model/Renaming.php} | 6 +- module/Core/src/Tag/TagService.php | 4 +- module/Core/src/Tag/TagServiceInterface.php | 4 +- .../Exception/TagConflictExceptionTest.php | 4 +- module/Core/test/Tag/TagServiceTest.php | 10 +-- .../Rest/src/Action/Tag/UpdateTagAction.php | 4 +- module/Rest/src/Entity/ApiKey.php | 3 +- module/Rest/src/Service/ApiKeyService.php | 41 ++++++++++-- .../src/Service/ApiKeyServiceInterface.php | 6 ++ .../test/Action/Tag/UpdateTagActionTest.php | 4 +- .../Rest/test/Service/ApiKeyServiceTest.php | 63 +++++++++++++++++++ 14 files changed, 132 insertions(+), 31 deletions(-) rename module/Core/src/{Tag/Model/TagRenaming.php => Model/Renaming.php} (86%) diff --git a/module/CLI/src/Command/Tag/RenameTagCommand.php b/module/CLI/src/Command/Tag/RenameTagCommand.php index fdc0f0ce..5830858e 100644 --- a/module/CLI/src/Command/Tag/RenameTagCommand.php +++ b/module/CLI/src/Command/Tag/RenameTagCommand.php @@ -7,7 +7,7 @@ namespace Shlinkio\Shlink\CLI\Command\Tag; use Shlinkio\Shlink\CLI\Util\ExitCode; use Shlinkio\Shlink\Core\Exception\TagConflictException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; -use Shlinkio\Shlink\Core\Tag\Model\TagRenaming; +use Shlinkio\Shlink\Core\Model\Renaming; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; @@ -40,7 +40,7 @@ class RenameTagCommand extends Command $newName = $input->getArgument('newName'); try { - $this->tagService->renameTag(TagRenaming::fromNames($oldName, $newName)); + $this->tagService->renameTag(Renaming::fromNames($oldName, $newName)); $io->success('Tag properly renamed.'); return ExitCode::EXIT_SUCCESS; } catch (TagNotFoundException | TagConflictException $e) { diff --git a/module/CLI/test/Command/Tag/RenameTagCommandTest.php b/module/CLI/test/Command/Tag/RenameTagCommandTest.php index 296926b8..e7fb630d 100644 --- a/module/CLI/test/Command/Tag/RenameTagCommandTest.php +++ b/module/CLI/test/Command/Tag/RenameTagCommandTest.php @@ -9,8 +9,8 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\CLI\Command\Tag\RenameTagCommand; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; +use Shlinkio\Shlink\Core\Model\Renaming; use Shlinkio\Shlink\Core\Tag\Entity\Tag; -use Shlinkio\Shlink\Core\Tag\Model\TagRenaming; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use ShlinkioTest\Shlink\CLI\Util\CliTestUtils; use Symfony\Component\Console\Tester\CommandTester; @@ -32,7 +32,7 @@ class RenameTagCommandTest extends TestCase $oldName = 'foo'; $newName = 'bar'; $this->tagService->expects($this->once())->method('renameTag')->with( - TagRenaming::fromNames($oldName, $newName), + Renaming::fromNames($oldName, $newName), )->willThrowException(TagNotFoundException::fromTag('foo')); $this->commandTester->execute([ @@ -50,7 +50,7 @@ class RenameTagCommandTest extends TestCase $oldName = 'foo'; $newName = 'bar'; $this->tagService->expects($this->once())->method('renameTag')->with( - TagRenaming::fromNames($oldName, $newName), + Renaming::fromNames($oldName, $newName), )->willReturn(new Tag($newName)); $this->commandTester->execute([ diff --git a/module/Core/src/Exception/TagConflictException.php b/module/Core/src/Exception/TagConflictException.php index 0fc5c317..e05754c7 100644 --- a/module/Core/src/Exception/TagConflictException.php +++ b/module/Core/src/Exception/TagConflictException.php @@ -7,7 +7,7 @@ namespace Shlinkio\Shlink\Core\Exception; use Fig\Http\Message\StatusCodeInterface; use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait; use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface; -use Shlinkio\Shlink\Core\Tag\Model\TagRenaming; +use Shlinkio\Shlink\Core\Model\Renaming; use function Shlinkio\Shlink\Core\toProblemDetailsType; use function sprintf; @@ -19,7 +19,7 @@ class TagConflictException extends RuntimeException implements ProblemDetailsExc private const TITLE = 'Tag conflict'; public const ERROR_CODE = 'tag-conflict'; - public static function forExistingTag(TagRenaming $renaming): self + public static function forExistingTag(Renaming $renaming): self { $e = new self(sprintf('You cannot rename tag %s, because it already exists', $renaming->toString())); diff --git a/module/Core/src/Tag/Model/TagRenaming.php b/module/Core/src/Model/Renaming.php similarity index 86% rename from module/Core/src/Tag/Model/TagRenaming.php rename to module/Core/src/Model/Renaming.php index 9c523b8b..e4cee870 100644 --- a/module/Core/src/Tag/Model/TagRenaming.php +++ b/module/Core/src/Model/Renaming.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace Shlinkio\Shlink\Core\Tag\Model; +namespace Shlinkio\Shlink\Core\Model; use Shlinkio\Shlink\Core\Exception\ValidationException; use function sprintf; -final class TagRenaming +final readonly class Renaming { - private function __construct(public readonly string $oldName, public readonly string $newName) + private function __construct(public string $oldName, public string $newName) { } diff --git a/module/Core/src/Tag/TagService.php b/module/Core/src/Tag/TagService.php index e3e5b92f..f91c018f 100644 --- a/module/Core/src/Tag/TagService.php +++ b/module/Core/src/Tag/TagService.php @@ -10,8 +10,8 @@ use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\Exception\ForbiddenTagOperationException; use Shlinkio\Shlink\Core\Exception\TagConflictException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; +use Shlinkio\Shlink\Core\Model\Renaming; use Shlinkio\Shlink\Core\Tag\Entity\Tag; -use Shlinkio\Shlink\Core\Tag\Model\TagRenaming; use Shlinkio\Shlink\Core\Tag\Model\TagsParams; use Shlinkio\Shlink\Core\Tag\Paginator\Adapter\TagsInfoPaginatorAdapter; use Shlinkio\Shlink\Core\Tag\Paginator\Adapter\TagsPaginatorAdapter; @@ -74,7 +74,7 @@ readonly class TagService implements TagServiceInterface /** * @inheritDoc */ - public function renameTag(TagRenaming $renaming, ApiKey|null $apiKey = null): Tag + public function renameTag(Renaming $renaming, ApiKey|null $apiKey = null): Tag { if (ApiKey::isShortUrlRestricted($apiKey)) { throw ForbiddenTagOperationException::forRenaming(); diff --git a/module/Core/src/Tag/TagServiceInterface.php b/module/Core/src/Tag/TagServiceInterface.php index c09370cf..a22e2ec8 100644 --- a/module/Core/src/Tag/TagServiceInterface.php +++ b/module/Core/src/Tag/TagServiceInterface.php @@ -8,9 +8,9 @@ use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\Exception\ForbiddenTagOperationException; use Shlinkio\Shlink\Core\Exception\TagConflictException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; +use Shlinkio\Shlink\Core\Model\Renaming; use Shlinkio\Shlink\Core\Tag\Entity\Tag; use Shlinkio\Shlink\Core\Tag\Model\TagInfo; -use Shlinkio\Shlink\Core\Tag\Model\TagRenaming; use Shlinkio\Shlink\Core\Tag\Model\TagsParams; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -37,5 +37,5 @@ interface TagServiceInterface * @throws TagConflictException * @throws ForbiddenTagOperationException */ - public function renameTag(TagRenaming $renaming, ApiKey|null $apiKey = null): Tag; + public function renameTag(Renaming $renaming, ApiKey|null $apiKey = null): Tag; } diff --git a/module/Core/test/Exception/TagConflictExceptionTest.php b/module/Core/test/Exception/TagConflictExceptionTest.php index 2f4bd66a..9126e6f3 100644 --- a/module/Core/test/Exception/TagConflictExceptionTest.php +++ b/module/Core/test/Exception/TagConflictExceptionTest.php @@ -7,7 +7,7 @@ namespace ShlinkioTest\Shlink\Core\Exception; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Exception\TagConflictException; -use Shlinkio\Shlink\Core\Tag\Model\TagRenaming; +use Shlinkio\Shlink\Core\Model\Renaming; use function sprintf; @@ -19,7 +19,7 @@ class TagConflictExceptionTest extends TestCase $oldName = 'foo'; $newName = 'bar'; $expectedMessage = sprintf('You cannot rename tag %s to %s, because it already exists', $oldName, $newName); - $e = TagConflictException::forExistingTag(TagRenaming::fromNames($oldName, $newName)); + $e = TagConflictException::forExistingTag(Renaming::fromNames($oldName, $newName)); self::assertEquals($expectedMessage, $e->getMessage()); self::assertEquals($expectedMessage, $e->getDetail()); diff --git a/module/Core/test/Tag/TagServiceTest.php b/module/Core/test/Tag/TagServiceTest.php index 7e82eb1c..c1fa8ee7 100644 --- a/module/Core/test/Tag/TagServiceTest.php +++ b/module/Core/test/Tag/TagServiceTest.php @@ -13,9 +13,9 @@ use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Exception\ForbiddenTagOperationException; use Shlinkio\Shlink\Core\Exception\TagConflictException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; +use Shlinkio\Shlink\Core\Model\Renaming; use Shlinkio\Shlink\Core\Tag\Entity\Tag; use Shlinkio\Shlink\Core\Tag\Model\TagInfo; -use Shlinkio\Shlink\Core\Tag\Model\TagRenaming; use Shlinkio\Shlink\Core\Tag\Model\TagsListFiltering; use Shlinkio\Shlink\Core\Tag\Model\TagsParams; use Shlinkio\Shlink\Core\Tag\Repository\TagRepository; @@ -127,7 +127,7 @@ class TagServiceTest extends TestCase $this->repo->expects($this->once())->method('findOneBy')->willReturn(null); $this->expectException(TagNotFoundException::class); - $this->service->renameTag(TagRenaming::fromNames('foo', 'bar'), $apiKey); + $this->service->renameTag(Renaming::fromNames('foo', 'bar'), $apiKey); } #[Test, DataProvider('provideValidRenames')] @@ -139,7 +139,7 @@ class TagServiceTest extends TestCase $this->repo->expects($this->exactly($count > 0 ? 0 : 1))->method('count')->willReturn($count); $this->em->expects($this->once())->method('flush'); - $tag = $this->service->renameTag(TagRenaming::fromNames($oldName, $newName)); + $tag = $this->service->renameTag(Renaming::fromNames($oldName, $newName)); self::assertSame($expected, $tag); self::assertEquals($newName, (string) $tag); @@ -160,7 +160,7 @@ class TagServiceTest extends TestCase $this->expectException(TagConflictException::class); - $this->service->renameTag(TagRenaming::fromNames('foo', 'bar'), $apiKey); + $this->service->renameTag(Renaming::fromNames('foo', 'bar'), $apiKey); } #[Test] @@ -172,7 +172,7 @@ class TagServiceTest extends TestCase $this->expectExceptionMessage('You are not allowed to rename tags'); $this->service->renameTag( - TagRenaming::fromNames('foo', 'bar'), + Renaming::fromNames('foo', 'bar'), ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls())), ); } diff --git a/module/Rest/src/Action/Tag/UpdateTagAction.php b/module/Rest/src/Action/Tag/UpdateTagAction.php index 016d008b..e1dc1611 100644 --- a/module/Rest/src/Action/Tag/UpdateTagAction.php +++ b/module/Rest/src/Action/Tag/UpdateTagAction.php @@ -7,7 +7,7 @@ namespace Shlinkio\Shlink\Rest\Action\Tag; use Laminas\Diactoros\Response\EmptyResponse; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use Shlinkio\Shlink\Core\Tag\Model\TagRenaming; +use Shlinkio\Shlink\Core\Model\Renaming; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; @@ -27,7 +27,7 @@ class UpdateTagAction extends AbstractRestAction $body = $request->getParsedBody(); $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); - $this->tagService->renameTag(TagRenaming::fromArray($body), $apiKey); + $this->tagService->renameTag(Renaming::fromArray($body), $apiKey); return new EmptyResponse(); } } diff --git a/module/Rest/src/Entity/ApiKey.php b/module/Rest/src/Entity/ApiKey.php index fea06a1d..c9cdd3a6 100644 --- a/module/Rest/src/Entity/ApiKey.php +++ b/module/Rest/src/Entity/ApiKey.php @@ -23,7 +23,8 @@ class ApiKey extends AbstractEntity */ private function __construct( public readonly string $key, - public readonly string $name, + // TODO Use a property hook to allow public read but private write + public string $name, public readonly Chronos|null $expirationDate = null, private bool $enabled = true, private Collection $roles = new ArrayCollection(), diff --git a/module/Rest/src/Service/ApiKeyService.php b/module/Rest/src/Service/ApiKeyService.php index 4b786c6d..38ed004d 100644 --- a/module/Rest/src/Service/ApiKeyService.php +++ b/module/Rest/src/Service/ApiKeyService.php @@ -6,10 +6,13 @@ namespace Shlinkio\Shlink\Rest\Service; use Doctrine\ORM\EntityManagerInterface; use Shlinkio\Shlink\Common\Exception\InvalidArgumentException; +use Shlinkio\Shlink\Core\Model\Renaming; use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\ApiKey\Repository\ApiKeyRepositoryInterface; use Shlinkio\Shlink\Rest\Entity\ApiKey; +use function sprintf; + readonly class ApiKeyService implements ApiKeyServiceInterface { public function __construct(private EntityManagerInterface $em, private ApiKeyRepositoryInterface $repo) @@ -74,11 +77,6 @@ readonly class ApiKeyService implements ApiKeyServiceInterface return $this->repo->findBy($conditions); } - private function findByKey(string $key): ApiKey|null - { - return $this->repo->findOneBy(['key' => ApiKey::hashKey($key)]); - } - /** * @inheritDoc */ @@ -86,4 +84,37 @@ readonly class ApiKeyService implements ApiKeyServiceInterface { return $this->repo->count(['name' => $apiKeyName]) > 0; } + + /** + * @inheritDoc + */ + public function renameApiKey(Renaming $apiKeyRenaming): ApiKey + { + $apiKey = $this->repo->findOneBy(['name' => $apiKeyRenaming->oldName]); + if ($apiKey === null) { + throw new InvalidArgumentException( + sprintf('API key with name "%s" could not be found', $apiKeyRenaming->oldName), + ); + } + + if (! $apiKeyRenaming->nameChanged()) { + return $apiKey; + } + + if ($this->existsWithName($apiKeyRenaming->newName)) { + throw new InvalidArgumentException( + sprintf('Another API key with name "%s" already exists', $apiKeyRenaming->newName), + ); + } + + $apiKey->name = $apiKeyRenaming->newName; + $this->em->flush(); + + return $apiKey; + } + + private function findByKey(string $key): ApiKey|null + { + return $this->repo->findOneBy(['key' => ApiKey::hashKey($key)]); + } } diff --git a/module/Rest/src/Service/ApiKeyServiceInterface.php b/module/Rest/src/Service/ApiKeyServiceInterface.php index 73773fba..4197b3fd 100644 --- a/module/Rest/src/Service/ApiKeyServiceInterface.php +++ b/module/Rest/src/Service/ApiKeyServiceInterface.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Rest\Service; use Shlinkio\Shlink\Common\Exception\InvalidArgumentException; +use Shlinkio\Shlink\Core\Model\Renaming; use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -36,4 +37,9 @@ interface ApiKeyServiceInterface * Check if an API key exists for provided name */ public function existsWithName(string $apiKeyName): bool; + + /** + * @throws InvalidArgumentException If an API key with oldName does not exist, or newName is in use by another one + */ + public function renameApiKey(Renaming $apiKeyRenaming): ApiKey; } diff --git a/module/Rest/test/Action/Tag/UpdateTagActionTest.php b/module/Rest/test/Action/Tag/UpdateTagActionTest.php index 73575baf..f83ee037 100644 --- a/module/Rest/test/Action/Tag/UpdateTagActionTest.php +++ b/module/Rest/test/Action/Tag/UpdateTagActionTest.php @@ -11,8 +11,8 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Core\Exception\ValidationException; +use Shlinkio\Shlink\Core\Model\Renaming; use Shlinkio\Shlink\Core\Tag\Entity\Tag; -use Shlinkio\Shlink\Core\Tag\Model\TagRenaming; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Rest\Action\Tag\UpdateTagAction; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -53,7 +53,7 @@ class UpdateTagActionTest extends TestCase 'newName' => 'bar', ]); $this->tagService->expects($this->once())->method('renameTag')->with( - TagRenaming::fromNames('foo', 'bar'), + Renaming::fromNames('foo', 'bar'), $this->isInstanceOf(ApiKey::class), )->willReturn(new Tag('bar')); diff --git a/module/Rest/test/Service/ApiKeyServiceTest.php b/module/Rest/test/Service/ApiKeyServiceTest.php index e304a651..f439e5f9 100644 --- a/module/Rest/test/Service/ApiKeyServiceTest.php +++ b/module/Rest/test/Service/ApiKeyServiceTest.php @@ -13,6 +13,7 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Common\Exception\InvalidArgumentException; use Shlinkio\Shlink\Core\Domain\Entity\Domain; +use Shlinkio\Shlink\Core\Model\Renaming; use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; use Shlinkio\Shlink\Rest\ApiKey\Repository\ApiKeyRepositoryInterface; @@ -188,4 +189,66 @@ class ApiKeyServiceTest extends TestCase $this->repo->expects($this->once())->method('count')->with(['name' => $name])->willReturn($count); self::assertEquals($this->service->existsWithName($name), $expected); } + + #[Test] + public function renameApiKeyThrowsExceptionIfApiKeyIsNotFound(): void + { + $renaming = Renaming::fromNames(oldName: 'old', newName: 'new'); + + $this->repo->expects($this->once())->method('findOneBy')->with(['name' => 'old'])->willReturn(null); + $this->repo->expects($this->never())->method('count'); + $this->em->expects($this->never())->method('flush'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('API key with name "old" could not be found'); + + $this->service->renameApiKey($renaming); + } + + #[Test] + public function renameApiKeyReturnsApiKeyVerbatimIfBothNamesAreEqual(): void + { + $renaming = Renaming::fromNames(oldName: 'same_value', newName: 'same_value'); + $apiKey = ApiKey::create(); + + $this->repo->expects($this->once())->method('findOneBy')->with(['name' => 'same_value'])->willReturn($apiKey); + $this->repo->expects($this->never())->method('count'); + $this->em->expects($this->never())->method('flush'); + + $result = $this->service->renameApiKey($renaming); + + self::assertSame($apiKey, $result); + } + + #[Test] + public function renameApiKeyThrowsExceptionIfNewNameIsInUse(): void + { + $renaming = Renaming::fromNames(oldName: 'old', newName: 'new'); + $apiKey = ApiKey::create(); + + $this->repo->expects($this->once())->method('findOneBy')->with(['name' => 'old'])->willReturn($apiKey); + $this->repo->expects($this->once())->method('count')->with(['name' => 'new'])->willReturn(1); + $this->em->expects($this->never())->method('flush'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Another API key with name "new" already exists'); + + $this->service->renameApiKey($renaming); + } + + #[Test] + public function renameApiKeyReturnsApiKeyWithNewName(): void + { + $renaming = Renaming::fromNames(oldName: 'old', newName: 'new'); + $apiKey = ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'old')); + + $this->repo->expects($this->once())->method('findOneBy')->with(['name' => 'old'])->willReturn($apiKey); + $this->repo->expects($this->once())->method('count')->with(['name' => 'new'])->willReturn(0); + $this->em->expects($this->once())->method('flush'); + + $result = $this->service->renameApiKey($renaming); + + self::assertSame($apiKey, $result); + self::assertEquals('new', $apiKey->name); + } } From b08c498b13ef247913ee024c32dd0256b54024ba Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 8 Nov 2024 08:47:49 +0100 Subject: [PATCH 30/80] Create command to rename API keys --- module/CLI/config/cli.config.php | 1 + module/CLI/config/dependencies.config.php | 2 + .../src/Command/Api/RenameApiKeyCommand.php | 77 +++++++++++++++++++ module/Rest/src/Service/ApiKeyService.php | 3 + 4 files changed, 83 insertions(+) create mode 100644 module/CLI/src/Command/Api/RenameApiKeyCommand.php diff --git a/module/CLI/config/cli.config.php b/module/CLI/config/cli.config.php index 8283a7b6..a554db40 100644 --- a/module/CLI/config/cli.config.php +++ b/module/CLI/config/cli.config.php @@ -28,6 +28,7 @@ return [ Command\Api\DisableKeyCommand::NAME => Command\Api\DisableKeyCommand::class, Command\Api\ListKeysCommand::NAME => Command\Api\ListKeysCommand::class, Command\Api\InitialApiKeyCommand::NAME => Command\Api\InitialApiKeyCommand::class, + Command\Api\RenameApiKeyCommand::NAME => Command\Api\RenameApiKeyCommand::class, Command\Tag\ListTagsCommand::NAME => Command\Tag\ListTagsCommand::class, Command\Tag\RenameTagCommand::NAME => Command\Tag\RenameTagCommand::class, diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php index 2f098998..76e7c4f5 100644 --- a/module/CLI/config/dependencies.config.php +++ b/module/CLI/config/dependencies.config.php @@ -59,6 +59,7 @@ return [ Command\Api\DisableKeyCommand::class => ConfigAbstractFactory::class, Command\Api\ListKeysCommand::class => ConfigAbstractFactory::class, Command\Api\InitialApiKeyCommand::class => ConfigAbstractFactory::class, + Command\Api\RenameApiKeyCommand::class => ConfigAbstractFactory::class, Command\Tag\ListTagsCommand::class => ConfigAbstractFactory::class, Command\Tag\RenameTagCommand::class => ConfigAbstractFactory::class, @@ -120,6 +121,7 @@ return [ Command\Api\DisableKeyCommand::class => [ApiKeyService::class], Command\Api\ListKeysCommand::class => [ApiKeyService::class], Command\Api\InitialApiKeyCommand::class => [ApiKeyService::class], + Command\Api\RenameApiKeyCommand::class => [ApiKeyService::class], Command\Tag\ListTagsCommand::class => [TagService::class], Command\Tag\RenameTagCommand::class => [TagService::class], diff --git a/module/CLI/src/Command/Api/RenameApiKeyCommand.php b/module/CLI/src/Command/Api/RenameApiKeyCommand.php new file mode 100644 index 00000000..f7e24992 --- /dev/null +++ b/module/CLI/src/Command/Api/RenameApiKeyCommand.php @@ -0,0 +1,77 @@ +setName(self::NAME) + ->setDescription('Renames an API key by name') + ->addArgument('oldName', InputArgument::REQUIRED, 'Current name of the API key to rename') + ->addArgument('newName', InputArgument::REQUIRED, 'New name to set to the API key'); + } + + protected function interact(InputInterface $input, OutputInterface $output): void + { + $io = new SymfonyStyle($input, $output); + $oldName = $input->getArgument('oldName'); + $newName = $input->getArgument('newName'); + + if ($oldName === null) { + $apiKeys = $this->apiKeyService->listKeys(); + $requestedOldName = $io->choice( + 'What API key do you want to rename?', + map($apiKeys, static fn (ApiKey $apiKey) => $apiKey->name), + ); + + $input->setArgument('oldName', $requestedOldName); + } + + if ($newName === null) { + $requestedNewName = $io->ask( + 'What is the new name you want to set?', + validator: static fn (string|null $value): string => $value !== null + ? $value + : throw new InvalidArgumentException('The new name cannot be empty'), + ); + + $input->setArgument('newName', $requestedNewName); + } + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $oldName = $input->getArgument('oldName'); + $newName = $input->getArgument('newName'); + + $this->apiKeyService->renameApiKey(Renaming::fromNames($oldName, $newName)); + $io->success('API key properly renamed'); + + return ExitCode::EXIT_SUCCESS; + } +} diff --git a/module/Rest/src/Service/ApiKeyService.php b/module/Rest/src/Service/ApiKeyService.php index 38ed004d..66876204 100644 --- a/module/Rest/src/Service/ApiKeyService.php +++ b/module/Rest/src/Service/ApiKeyService.php @@ -87,6 +87,9 @@ readonly class ApiKeyService implements ApiKeyServiceInterface /** * @inheritDoc + * @todo This method should be transactional and to a SELECT ... FROM UPDATE when checking if the new name exists, + * to avoid a race condition where the method is called twice in parallel for a new name that doesn't exist, + * causing two API keys to end up with the same name. */ public function renameApiKey(Renaming $apiKeyRenaming): ApiKey { From 6f837b3b91c5a676315b228d226cc9b590d9d405 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 8 Nov 2024 09:03:50 +0100 Subject: [PATCH 31/80] Move logic to determine if a new key has a duplicated name to the APiKeyService --- CHANGELOG.md | 12 +++++++- .../src/Command/Api/GenerateKeyCommand.php | 7 ----- .../Command/Api/GenerateKeyCommandTest.php | 17 ----------- module/Rest/src/Service/ApiKeyService.php | 19 +++++++------ .../src/Service/ApiKeyServiceInterface.php | 5 ---- .../Rest/test/Service/ApiKeyServiceTest.php | 28 +++++++++++-------- 6 files changed, 38 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6741b55..bd80fa59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#2207](https://github.com/shlinkio/shlink/issues/2207) Add `hasRedirectRules` flag to short URL API model. This flag tells if a specific short URL has any redirect rules attached to it. * [#1520](https://github.com/shlinkio/shlink/issues/1520) Allow short URLs list to be filtered by `domain`. - This change applies both to the `GET /short-urls` endpoint, via the `domain` query parameter, and the `short-url:list` console command, via the `--domain`|`-d` flag. + This change applies both to the `GET /short-urls` endpoint, via the `domain` query parameter, and the `short-url:list` console command, via the `--domain`|`-d` flag. ### Changed +* [#2193](https://github.com/shlinkio/shlink/issues/2193) API keys are now hashed using SHA256, instead of being saved in plain text. + + As a side effect, API key names have now become more important, and are considered unique. + + When people update to this Shlink version, existing API keys will be hashed for everything to continue working. + + In order to avoid data to be lost, plain-text keys will be written in the `name` field, either together with any existing name, or as the name itself. Then users are responsible for renaming them using the new `api-key:rename` command. + + For newly created API keys, it is recommended to provide a name, but if not provided, a name will be generated from a redacted version of the new API key. + * Update to Shlink PHP coding standard 2.4 * Update to `hidehalo/nanoid-php` 2.0 diff --git a/module/CLI/src/Command/Api/GenerateKeyCommand.php b/module/CLI/src/Command/Api/GenerateKeyCommand.php index 3a1432ac..9fc0bb1d 100644 --- a/module/CLI/src/Command/Api/GenerateKeyCommand.php +++ b/module/CLI/src/Command/Api/GenerateKeyCommand.php @@ -108,13 +108,6 @@ class GenerateKeyCommand extends Command roleDefinitions: $this->roleResolver->determineRoles($input), ); - if ($this->apiKeyService->existsWithName($apiKeyMeta->name)) { - $io->warning( - sprintf('An API key with name "%s" already exists. Try with a different ome', $apiKeyMeta->name), - ); - return ExitCode::EXIT_WARNING; - } - $apiKey = $this->apiKeyService->create($apiKeyMeta); $io->success(sprintf('Generated API key: "%s"', $apiKeyMeta->key)); diff --git a/module/CLI/test/Command/Api/GenerateKeyCommandTest.php b/module/CLI/test/Command/Api/GenerateKeyCommandTest.php index 10633b9a..1eb977bf 100644 --- a/module/CLI/test/Command/Api/GenerateKeyCommandTest.php +++ b/module/CLI/test/Command/Api/GenerateKeyCommandTest.php @@ -71,21 +71,4 @@ class GenerateKeyCommandTest extends TestCase self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode); } - - #[Test] - public function warningIsPrintedIfProvidedNameAlreadyExists(): void - { - $name = 'The API key'; - - $this->apiKeyService->expects($this->never())->method('create'); - $this->apiKeyService->expects($this->once())->method('existsWithName')->with($name)->willReturn(true); - - $exitCode = $this->commandTester->execute([ - '--name' => $name, - ]); - $output = $this->commandTester->getDisplay(); - - self::assertEquals(ExitCode::EXIT_WARNING, $exitCode); - self::assertStringContainsString('An API key with name "The API key" already exists.', $output); - } } diff --git a/module/Rest/src/Service/ApiKeyService.php b/module/Rest/src/Service/ApiKeyService.php index 66876204..f517dde5 100644 --- a/module/Rest/src/Service/ApiKeyService.php +++ b/module/Rest/src/Service/ApiKeyService.php @@ -21,7 +21,13 @@ readonly class ApiKeyService implements ApiKeyServiceInterface public function create(ApiKeyMeta $apiKeyMeta): ApiKey { + // TODO If name is auto-generated, do not throw. Instead, re-generate a new key $apiKey = ApiKey::fromMeta($apiKeyMeta); + if ($this->existsWithName($apiKey->name)) { + throw new InvalidArgumentException( + sprintf('Another API key with name "%s" already exists', $apiKeyMeta->name), + ); + } $this->em->persist($apiKey); $this->em->flush(); @@ -77,14 +83,6 @@ readonly class ApiKeyService implements ApiKeyServiceInterface return $this->repo->findBy($conditions); } - /** - * @inheritDoc - */ - public function existsWithName(string $apiKeyName): bool - { - return $this->repo->count(['name' => $apiKeyName]) > 0; - } - /** * @inheritDoc * @todo This method should be transactional and to a SELECT ... FROM UPDATE when checking if the new name exists, @@ -120,4 +118,9 @@ readonly class ApiKeyService implements ApiKeyServiceInterface { return $this->repo->findOneBy(['key' => ApiKey::hashKey($key)]); } + + private function existsWithName(string $apiKeyName): bool + { + return $this->repo->count(['name' => $apiKeyName]) > 0; + } } diff --git a/module/Rest/src/Service/ApiKeyServiceInterface.php b/module/Rest/src/Service/ApiKeyServiceInterface.php index 4197b3fd..c42505b7 100644 --- a/module/Rest/src/Service/ApiKeyServiceInterface.php +++ b/module/Rest/src/Service/ApiKeyServiceInterface.php @@ -33,11 +33,6 @@ interface ApiKeyServiceInterface */ public function listKeys(bool $enabledOnly = false): array; - /** - * Check if an API key exists for provided name - */ - public function existsWithName(string $apiKeyName): bool; - /** * @throws InvalidArgumentException If an API key with oldName does not exist, or newName is in use by another one */ diff --git a/module/Rest/test/Service/ApiKeyServiceTest.php b/module/Rest/test/Service/ApiKeyServiceTest.php index f439e5f9..adecfbd9 100644 --- a/module/Rest/test/Service/ApiKeyServiceTest.php +++ b/module/Rest/test/Service/ApiKeyServiceTest.php @@ -8,7 +8,6 @@ use Cake\Chronos\Chronos; use Doctrine\ORM\EntityManager; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; -use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Common\Exception\InvalidArgumentException; @@ -41,6 +40,9 @@ class ApiKeyServiceTest extends TestCase #[Test, DataProvider('provideCreationDate')] public function apiKeyIsProperlyCreated(Chronos|null $date, string|null $name, array $roles): void { + $this->repo->expects($this->once())->method('count')->with( + ! empty($name) ? ['name' => $name] : $this->isType('array'), + )->willReturn(0); $this->em->expects($this->once())->method('flush'); $this->em->expects($this->once())->method('persist')->with($this->isInstanceOf(ApiKey::class)); @@ -73,6 +75,19 @@ class ApiKeyServiceTest extends TestCase yield 'empty name' => [null, '', []]; } + #[Test] + public function exceptionIsThrownWhileCreatingIfNameIsInUse(): void + { + $this->repo->expects($this->once())->method('count')->with(['name' => 'the_name'])->willReturn(1); + $this->em->expects($this->never())->method('flush'); + $this->em->expects($this->never())->method('persist'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Another API key with name "the_name" already exists'); + + $this->service->create(ApiKeyMeta::fromParams(name: 'the_name')); + } + #[Test, DataProvider('provideInvalidApiKeys')] public function checkReturnsFalseForInvalidApiKeys(ApiKey|null $invalidKey): void { @@ -179,17 +194,6 @@ class ApiKeyServiceTest extends TestCase yield 'existing api keys' => [null]; } - #[Test] - #[TestWith([0, false])] - #[TestWith([1, true])] - #[TestWith([27, true])] - public function existsWithNameCountsEntriesInRepository(int $count, bool $expected): void - { - $name = 'the_key'; - $this->repo->expects($this->once())->method('count')->with(['name' => $name])->willReturn($count); - self::assertEquals($this->service->existsWithName($name), $expected); - } - #[Test] public function renameApiKeyThrowsExceptionIfApiKeyIsNotFound(): void { From 7e573bdb9b24bf06d7ad41afbd823cff13733b9c Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 8 Nov 2024 09:58:02 +0100 Subject: [PATCH 32/80] Add tests for RenameApiKeyCOmmand and ApiKeyMeta --- .../Command/Api/RenameApiKeyCommandTest.php | 83 +++++++++++++++++++ .../Rest/test/ApiKey/Model/ApiKeyMetaTest.php | 35 ++++++++ 2 files changed, 118 insertions(+) create mode 100644 module/CLI/test/Command/Api/RenameApiKeyCommandTest.php create mode 100644 module/Rest/test/ApiKey/Model/ApiKeyMetaTest.php diff --git a/module/CLI/test/Command/Api/RenameApiKeyCommandTest.php b/module/CLI/test/Command/Api/RenameApiKeyCommandTest.php new file mode 100644 index 00000000..41e5689f --- /dev/null +++ b/module/CLI/test/Command/Api/RenameApiKeyCommandTest.php @@ -0,0 +1,83 @@ +apiKeyService = $this->createMock(ApiKeyServiceInterface::class); + $this->commandTester = CliTestUtils::testerForCommand(new RenameApiKeyCommand($this->apiKeyService)); + } + + #[Test] + public function oldNameIsRequestedIfNotProvided(): void + { + $oldName = 'old name'; + $newName = 'new name'; + + $this->apiKeyService->expects($this->once())->method('listKeys')->willReturn([ + ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'foo')), + ApiKey::fromMeta(ApiKeyMeta::fromParams(name: $oldName)), + ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'bar')), + ]); + $this->apiKeyService->expects($this->once())->method('renameApiKey')->with( + Renaming::fromNames($oldName, $newName), + ); + + $this->commandTester->setInputs([$oldName]); + $this->commandTester->execute([ + 'newName' => $newName, + ]); + } + + #[Test] + public function newNameIsRequestedIfNotProvided(): void + { + $oldName = 'old name'; + $newName = 'new name'; + + $this->apiKeyService->expects($this->never())->method('listKeys'); + $this->apiKeyService->expects($this->once())->method('renameApiKey')->with( + Renaming::fromNames($oldName, $newName), + ); + + $this->commandTester->setInputs([$newName]); + $this->commandTester->execute([ + 'oldName' => $oldName, + ]); + } + + #[Test] + public function apiIsRenamedWithProvidedNames(): void + { + $oldName = 'old name'; + $newName = 'new name'; + + $this->apiKeyService->expects($this->never())->method('listKeys'); + $this->apiKeyService->expects($this->once())->method('renameApiKey')->with( + Renaming::fromNames($oldName, $newName), + ); + + $this->commandTester->execute([ + 'oldName' => $oldName, + 'newName' => $newName, + ]); + } +} diff --git a/module/Rest/test/ApiKey/Model/ApiKeyMetaTest.php b/module/Rest/test/ApiKey/Model/ApiKeyMetaTest.php new file mode 100644 index 00000000..dfd5b9f9 --- /dev/null +++ b/module/Rest/test/ApiKey/Model/ApiKeyMetaTest.php @@ -0,0 +1,35 @@ +name); + } + + public static function provideNames(): iterable + { + yield 'name' => [null, 'the name', static fn (ApiKeyMeta $meta) => 'the name']; + yield 'key' => ['the key', null, static fn (ApiKeyMeta $meta) => 'the key']; + yield 'generated key' => [null, null, static fn (ApiKeyMeta $meta) => sprintf( + '%s-****-****-****-************', + substr($meta->key, offset: 0, length: 8), + )]; + } +} From dba9302f78242a735cd8e35f3248c55304fb0edc Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 9 Nov 2024 09:25:01 +0100 Subject: [PATCH 33/80] Inject TagRepository in TagService, instead of getting it from EntityManager --- module/Core/config/dependencies.config.php | 3 ++- .../Tag/Repository/TagRepositoryInterface.php | 6 ++--- module/Core/src/Tag/TagService.php | 23 +++++-------------- module/Core/test/Tag/TagServiceTest.php | 5 ++-- 4 files changed, 13 insertions(+), 24 deletions(-) diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 552d5e2a..67b6bff6 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -64,6 +64,7 @@ return [ ], Tag\TagService::class => ConfigAbstractFactory::class, + Tag\Repository\TagRepository::class => [EntityRepositoryFactory::class, Tag\Entity\Tag::class], Domain\DomainService::class => ConfigAbstractFactory::class, @@ -153,7 +154,7 @@ return [ Visit\Geolocation\VisitLocator::class => ['em', Visit\Repository\VisitIterationRepository::class], Visit\Geolocation\VisitToLocationHelper::class => [IpLocationResolverInterface::class], Visit\VisitsStatsHelper::class => ['em'], - Tag\TagService::class => ['em'], + Tag\TagService::class => ['em', Tag\Repository\TagRepository::class], ShortUrl\DeleteShortUrlService::class => [ 'em', Config\Options\DeleteShortUrlsOptions::class, diff --git a/module/Core/src/Tag/Repository/TagRepositoryInterface.php b/module/Core/src/Tag/Repository/TagRepositoryInterface.php index 236beb14..b0601b3b 100644 --- a/module/Core/src/Tag/Repository/TagRepositoryInterface.php +++ b/module/Core/src/Tag/Repository/TagRepositoryInterface.php @@ -4,15 +4,15 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Tag\Repository; -use Doctrine\Persistence\ObjectRepository; use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepositoryInterface; +use Shlinkio\Shlink\Core\Repository\EntityRepositoryInterface; use Shlinkio\Shlink\Core\Tag\Entity\Tag; use Shlinkio\Shlink\Core\Tag\Model\TagInfo; use Shlinkio\Shlink\Core\Tag\Model\TagsListFiltering; use Shlinkio\Shlink\Rest\Entity\ApiKey; -/** @extends ObjectRepository */ -interface TagRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface +/** @extends EntityRepositoryInterface */ +interface TagRepositoryInterface extends EntityRepositoryInterface, EntitySpecificationRepositoryInterface { public function deleteByName(array $names): int; diff --git a/module/Core/src/Tag/TagService.php b/module/Core/src/Tag/TagService.php index f91c018f..3681d454 100644 --- a/module/Core/src/Tag/TagService.php +++ b/module/Core/src/Tag/TagService.php @@ -15,13 +15,12 @@ use Shlinkio\Shlink\Core\Tag\Entity\Tag; use Shlinkio\Shlink\Core\Tag\Model\TagsParams; use Shlinkio\Shlink\Core\Tag\Paginator\Adapter\TagsInfoPaginatorAdapter; use Shlinkio\Shlink\Core\Tag\Paginator\Adapter\TagsPaginatorAdapter; -use Shlinkio\Shlink\Core\Tag\Repository\TagRepository; use Shlinkio\Shlink\Core\Tag\Repository\TagRepositoryInterface; use Shlinkio\Shlink\Rest\Entity\ApiKey; readonly class TagService implements TagServiceInterface { - public function __construct(private ORM\EntityManagerInterface $em) + public function __construct(private ORM\EntityManagerInterface $em, private TagRepositoryInterface $repo) { } @@ -30,9 +29,7 @@ readonly class TagService implements TagServiceInterface */ public function listTags(TagsParams $params, ApiKey|null $apiKey = null): Paginator { - /** @var TagRepository $repo */ - $repo = $this->em->getRepository(Tag::class); - return $this->createPaginator(new TagsPaginatorAdapter($repo, $params, $apiKey), $params); + return $this->createPaginator(new TagsPaginatorAdapter($this->repo, $params, $apiKey), $params); } /** @@ -40,9 +37,7 @@ readonly class TagService implements TagServiceInterface */ public function tagsInfo(TagsParams $params, ApiKey|null $apiKey = null): Paginator { - /** @var TagRepositoryInterface $repo */ - $repo = $this->em->getRepository(Tag::class); - return $this->createPaginator(new TagsInfoPaginatorAdapter($repo, $params, $apiKey), $params); + return $this->createPaginator(new TagsInfoPaginatorAdapter($this->repo, $params, $apiKey), $params); } /** @@ -66,9 +61,7 @@ readonly class TagService implements TagServiceInterface throw ForbiddenTagOperationException::forDeletion(); } - /** @var TagRepository $repo */ - $repo = $this->em->getRepository(Tag::class); - $repo->deleteByName($tagNames); + $this->repo->deleteByName($tagNames); } /** @@ -80,16 +73,12 @@ readonly class TagService implements TagServiceInterface throw ForbiddenTagOperationException::forRenaming(); } - /** @var TagRepository $repo */ - $repo = $this->em->getRepository(Tag::class); - - /** @var Tag|null $tag */ - $tag = $repo->findOneBy(['name' => $renaming->oldName]); + $tag = $this->repo->findOneBy(['name' => $renaming->oldName]); if ($tag === null) { throw TagNotFoundException::fromTag($renaming->oldName); } - $newNameExists = $renaming->nameChanged() && $repo->count(['name' => $renaming->newName]) > 0; + $newNameExists = $renaming->nameChanged() && $this->repo->count(['name' => $renaming->newName]) > 0; if ($newNameExists) { throw TagConflictException::forExistingTag($renaming); } diff --git a/module/Core/test/Tag/TagServiceTest.php b/module/Core/test/Tag/TagServiceTest.php index c1fa8ee7..4080986f 100644 --- a/module/Core/test/Tag/TagServiceTest.php +++ b/module/Core/test/Tag/TagServiceTest.php @@ -35,9 +35,8 @@ class TagServiceTest extends TestCase { $this->em = $this->createMock(EntityManagerInterface::class); $this->repo = $this->createMock(TagRepository::class); - $this->em->method('getRepository')->with(Tag::class)->willReturn($this->repo); - $this->service = new TagService($this->em); + $this->service = new TagService($this->em, $this->repo); } #[Test] @@ -166,7 +165,7 @@ class TagServiceTest extends TestCase #[Test] public function renamingTagThrowsExceptionWhenProvidedApiKeyIsNotAdmin(): void { - $this->em->expects($this->never())->method('getRepository')->with(Tag::class); + $this->repo->expects($this->never())->method('findOneBy'); $this->expectExceptionMessage(ForbiddenTagOperationException::class); $this->expectExceptionMessage('You are not allowed to rename tags'); From 102169b6c700645745a51f0465cf27d7029def30 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 9 Nov 2024 09:34:24 +0100 Subject: [PATCH 34/80] Inject DomainRepository in DomainService --- module/Core/config/dependencies.config.php | 7 ++++- module/Core/src/Domain/DomainService.php | 14 +++++----- module/Core/test/Domain/DomainServiceTest.php | 28 +++++++++---------- 3 files changed, 27 insertions(+), 22 deletions(-) diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 67b6bff6..9852bdad 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -67,6 +67,7 @@ return [ Tag\Repository\TagRepository::class => [EntityRepositoryFactory::class, Tag\Entity\Tag::class], Domain\DomainService::class => ConfigAbstractFactory::class, + Domain\Repository\DomainRepository::class => [EntityRepositoryFactory::class, Domain\Entity\Domain::class], Visit\VisitsTracker::class => ConfigAbstractFactory::class, Visit\RequestTracker::class => ConfigAbstractFactory::class, @@ -167,7 +168,11 @@ return [ ShortUrl\ShortUrlResolver::class, ], ShortUrl\Helper\ShortCodeUniquenessHelper::class => ['em', Config\Options\UrlShortenerOptions::class], - Domain\DomainService::class => ['em', Config\Options\UrlShortenerOptions::class], + Domain\DomainService::class => [ + 'em', + Config\Options\UrlShortenerOptions::class, + Domain\Repository\DomainRepository::class, + ], Util\DoctrineBatchHelper::class => ['em'], Util\RedirectResponseHelper::class => [Config\Options\RedirectOptions::class], diff --git a/module/Core/src/Domain/DomainService.php b/module/Core/src/Domain/DomainService.php index 18d66328..52bd6082 100644 --- a/module/Core/src/Domain/DomainService.php +++ b/module/Core/src/Domain/DomainService.php @@ -19,8 +19,11 @@ use function array_map; readonly class DomainService implements DomainServiceInterface { - public function __construct(private EntityManagerInterface $em, private UrlShortenerOptions $urlShortenerOptions) - { + public function __construct( + private EntityManagerInterface $em, + private UrlShortenerOptions $urlShortenerOptions, + private DomainRepositoryInterface $repo, + ) { } /** @@ -49,9 +52,7 @@ readonly class DomainService implements DomainServiceInterface */ private function defaultDomainAndRest(ApiKey|null $apiKey): array { - /** @var DomainRepositoryInterface $repo */ - $repo = $this->em->getRepository(Domain::class); - $allDomains = $repo->findDomains($apiKey); + $allDomains = $this->repo->findDomains($apiKey); $defaultDomain = null; $restOfDomains = []; @@ -71,7 +72,6 @@ readonly class DomainService implements DomainServiceInterface */ public function getDomain(string $domainId): Domain { - /** @var Domain|null $domain */ $domain = $this->em->find(Domain::class, $domainId); if ($domain === null) { throw DomainNotFoundException::fromId($domainId); @@ -82,7 +82,7 @@ readonly class DomainService implements DomainServiceInterface public function findByAuthority(string $authority, ApiKey|null $apiKey = null): Domain|null { - return $this->em->getRepository(Domain::class)->findOneByAuthority($authority, $apiKey); + return $this->repo->findOneByAuthority($authority, $apiKey); } /** diff --git a/module/Core/test/Domain/DomainServiceTest.php b/module/Core/test/Domain/DomainServiceTest.php index b7f78c6b..fb601d51 100644 --- a/module/Core/test/Domain/DomainServiceTest.php +++ b/module/Core/test/Domain/DomainServiceTest.php @@ -15,7 +15,7 @@ use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\Domain\DomainService; use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\Domain\Model\DomainItem; -use Shlinkio\Shlink\Core\Domain\Repository\DomainRepository; +use Shlinkio\Shlink\Core\Domain\Repository\DomainRepositoryInterface; use Shlinkio\Shlink\Core\Exception\DomainNotFoundException; use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; @@ -25,19 +25,23 @@ class DomainServiceTest extends TestCase { private DomainService $domainService; private MockObject & EntityManagerInterface $em; + private MockObject & DomainRepositoryInterface $repo; protected function setUp(): void { $this->em = $this->createMock(EntityManagerInterface::class); - $this->domainService = new DomainService($this->em, new UrlShortenerOptions(defaultDomain: 'default.com')); + $this->repo = $this->createMock(DomainRepositoryInterface::class); + $this->domainService = new DomainService( + $this->em, + new UrlShortenerOptions(defaultDomain: 'default.com'), + $this->repo, + ); } #[Test, DataProvider('provideExcludedDomains')] public function listDomainsDelegatesIntoRepository(array $domains, array $expectedResult, ApiKey|null $apiKey): void { - $repo = $this->createMock(DomainRepository::class); - $repo->expects($this->once())->method('findDomains')->with($apiKey)->willReturn($domains); - $this->em->expects($this->once())->method('getRepository')->with(Domain::class)->willReturn($repo); + $this->repo->expects($this->once())->method('findDomains')->with($apiKey)->willReturn($domains); $result = $this->domainService->listDomains($apiKey); @@ -127,11 +131,9 @@ class DomainServiceTest extends TestCase public function getOrCreateAlwaysPersistsDomain(Domain|null $foundDomain, ApiKey|null $apiKey): void { $authority = 'example.com'; - $repo = $this->createMock(DomainRepository::class); - $repo->method('findOneByAuthority')->with($authority, $apiKey)->willReturn( + $this->repo->expects($this->once())->method('findOneByAuthority')->with($authority, $apiKey)->willReturn( $foundDomain, ); - $this->em->expects($this->once())->method('getRepository')->with(Domain::class)->willReturn($repo); $this->em->expects($this->once())->method('persist')->with($foundDomain ?? $this->isInstanceOf(Domain::class)); $this->em->expects($this->once())->method('flush'); @@ -149,9 +151,7 @@ class DomainServiceTest extends TestCase $domain = Domain::withAuthority($authority); $domain->setId('1'); $apiKey = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forDomain($domain))); - $repo = $this->createMock(DomainRepository::class); - $repo->method('findOneByAuthority')->with($authority, $apiKey)->willReturn(null); - $this->em->expects($this->once())->method('getRepository')->with(Domain::class)->willReturn($repo); + $this->repo->expects($this->once())->method('findOneByAuthority')->with($authority, $apiKey)->willReturn(null); $this->em->expects($this->never())->method('persist'); $this->em->expects($this->never())->method('flush'); @@ -166,9 +166,9 @@ class DomainServiceTest extends TestCase ApiKey|null $apiKey, ): void { $authority = 'example.com'; - $repo = $this->createMock(DomainRepository::class); - $repo->method('findOneByAuthority')->with($authority, $apiKey)->willReturn($foundDomain); - $this->em->expects($this->once())->method('getRepository')->with(Domain::class)->willReturn($repo); + $this->repo->expects($this->once())->method('findOneByAuthority')->with($authority, $apiKey)->willReturn( + $foundDomain, + ); $this->em->expects($this->once())->method('persist')->with($foundDomain ?? $this->isInstanceOf(Domain::class)); $this->em->expects($this->once())->method('flush'); From 532102e66252839b7d8756ab644102ed7964e83c Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 9 Nov 2024 09:39:56 +0100 Subject: [PATCH 35/80] Inject ShortUrlRepository in ShortUrlResolver --- module/Core/config/dependencies.config.php | 9 ++++++++- module/Core/src/Importer/ImportedLinksProcessor.php | 10 +++++----- module/Core/src/ShortUrl/ShortUrlResolver.php | 13 ++++--------- module/Core/test/ShortUrl/ShortUrlResolverTest.php | 10 +--------- 4 files changed, 18 insertions(+), 24 deletions(-) diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 9852bdad..4afb28d5 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -50,6 +50,10 @@ return [ ShortUrl\Transformer\ShortUrlDataTransformer::class => ConfigAbstractFactory::class, ShortUrl\Middleware\ExtraPathRedirectMiddleware::class => ConfigAbstractFactory::class, ShortUrl\Middleware\TrimTrailingSlashMiddleware::class => ConfigAbstractFactory::class, + ShortUrl\Repository\ShortUrlRepository::class => [ + EntityRepositoryFactory::class, + ShortUrl\Entity\ShortUrl::class, + ], ShortUrl\Repository\ShortUrlListRepository::class => [ EntityRepositoryFactory::class, ShortUrl\Entity\ShortUrl::class, @@ -162,7 +166,10 @@ return [ ShortUrl\ShortUrlResolver::class, ShortUrl\Repository\ExpiredShortUrlsRepository::class, ], - ShortUrl\ShortUrlResolver::class => ['em', Config\Options\UrlShortenerOptions::class], + ShortUrl\ShortUrlResolver::class => [ + ShortUrl\Repository\ShortUrlRepository::class, + Config\Options\UrlShortenerOptions::class, + ], ShortUrl\ShortUrlVisitsDeleter::class => [ Visit\Repository\VisitDeleterRepository::class, ShortUrl\ShortUrlResolver::class, diff --git a/module/Core/src/Importer/ImportedLinksProcessor.php b/module/Core/src/Importer/ImportedLinksProcessor.php index 16da0a09..266e9a7a 100644 --- a/module/Core/src/Importer/ImportedLinksProcessor.php +++ b/module/Core/src/Importer/ImportedLinksProcessor.php @@ -25,13 +25,13 @@ use Throwable; use function Shlinkio\Shlink\Core\normalizeDate; use function sprintf; -class ImportedLinksProcessor implements ImportedLinksProcessorInterface +readonly class ImportedLinksProcessor implements ImportedLinksProcessorInterface { public function __construct( - private readonly EntityManagerInterface $em, - private readonly ShortUrlRelationResolverInterface $relationResolver, - private readonly ShortCodeUniquenessHelperInterface $shortCodeHelper, - private readonly DoctrineBatchHelperInterface $batchHelper, + private EntityManagerInterface $em, + private ShortUrlRelationResolverInterface $relationResolver, + private ShortCodeUniquenessHelperInterface $shortCodeHelper, + private DoctrineBatchHelperInterface $batchHelper, ) { } diff --git a/module/Core/src/ShortUrl/ShortUrlResolver.php b/module/Core/src/ShortUrl/ShortUrlResolver.php index 0f32768d..408988a5 100644 --- a/module/Core/src/ShortUrl/ShortUrlResolver.php +++ b/module/Core/src/ShortUrl/ShortUrlResolver.php @@ -4,18 +4,17 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl; -use Doctrine\ORM\EntityManagerInterface; use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; -use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepository; +use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepositoryInterface; use Shlinkio\Shlink\Rest\Entity\ApiKey; readonly class ShortUrlResolver implements ShortUrlResolverInterface { public function __construct( - private EntityManagerInterface $em, + private ShortUrlRepositoryInterface $repo, private UrlShortenerOptions $urlShortenerOptions, ) { } @@ -25,9 +24,7 @@ readonly class ShortUrlResolver implements ShortUrlResolverInterface */ public function resolveShortUrl(ShortUrlIdentifier $identifier, ApiKey|null $apiKey = null): ShortUrl { - /** @var ShortUrlRepository $shortUrlRepo */ - $shortUrlRepo = $this->em->getRepository(ShortUrl::class); - $shortUrl = $shortUrlRepo->findOne($identifier, $apiKey?->spec()); + $shortUrl = $this->repo->findOne($identifier, $apiKey?->spec()); if ($shortUrl === null) { throw ShortUrlNotFoundException::fromNotFound($identifier); } @@ -53,9 +50,7 @@ readonly class ShortUrlResolver implements ShortUrlResolverInterface */ public function resolvePublicShortUrl(ShortUrlIdentifier $identifier): ShortUrl { - /** @var ShortUrlRepository $shortUrlRepo */ - $shortUrlRepo = $this->em->getRepository(ShortUrl::class); - $shortUrl = $shortUrlRepo->findOneWithDomainFallback($identifier, $this->urlShortenerOptions->mode); + $shortUrl = $this->repo->findOneWithDomainFallback($identifier, $this->urlShortenerOptions->mode); if ($shortUrl === null) { throw ShortUrlNotFoundException::fromNotFound($identifier); } diff --git a/module/Core/test/ShortUrl/ShortUrlResolverTest.php b/module/Core/test/ShortUrl/ShortUrlResolverTest.php index 24571b41..4d199d67 100644 --- a/module/Core/test/ShortUrl/ShortUrlResolverTest.php +++ b/module/Core/test/ShortUrl/ShortUrlResolverTest.php @@ -6,7 +6,6 @@ namespace ShlinkioTest\Shlink\Core\ShortUrl; use Cake\Chronos\Chronos; use Doctrine\Common\Collections\ArrayCollection; -use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\DataProviderExternal; use PHPUnit\Framework\Attributes\Test; @@ -31,14 +30,12 @@ use function range; class ShortUrlResolverTest extends TestCase { private ShortUrlResolver $urlResolver; - private MockObject & EntityManagerInterface $em; private MockObject & ShortUrlRepository $repo; protected function setUp(): void { - $this->em = $this->createMock(EntityManagerInterface::class); $this->repo = $this->createMock(ShortUrlRepository::class); - $this->urlResolver = new ShortUrlResolver($this->em, new UrlShortenerOptions()); + $this->urlResolver = new ShortUrlResolver($this->repo, new UrlShortenerOptions()); } #[Test, DataProviderExternal(ApiKeyDataProviders::class, 'adminApiKeysProvider')] @@ -51,7 +48,6 @@ class ShortUrlResolverTest extends TestCase $this->repo->expects($this->once())->method('findOne')->with($identifier, $apiKey?->spec())->willReturn( $shortUrl, ); - $this->em->expects($this->once())->method('getRepository')->with(ShortUrl::class)->willReturn($this->repo); $result = $this->urlResolver->resolveShortUrl($identifier, $apiKey); @@ -65,7 +61,6 @@ class ShortUrlResolverTest extends TestCase $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode); $this->repo->expects($this->once())->method('findOne')->with($identifier, $apiKey?->spec())->willReturn(null); - $this->em->expects($this->once())->method('getRepository')->with(ShortUrl::class)->willReturn($this->repo); $this->expectException(ShortUrlNotFoundException::class); @@ -82,7 +77,6 @@ class ShortUrlResolverTest extends TestCase ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), ShortUrlMode::STRICT, )->willReturn($shortUrl); - $this->em->expects($this->once())->method('getRepository')->with(ShortUrl::class)->willReturn($this->repo); $result = $this->urlResolver->resolveEnabledShortUrl(ShortUrlIdentifier::fromShortCodeAndDomain($shortCode)); @@ -98,7 +92,6 @@ class ShortUrlResolverTest extends TestCase ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), ShortUrlMode::STRICT, )->willReturn(null); - $this->em->expects($this->once())->method('getRepository')->with(ShortUrl::class)->willReturn($this->repo); $this->expectException(ShortUrlNotFoundException::class); @@ -120,7 +113,6 @@ class ShortUrlResolverTest extends TestCase ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), ShortUrlMode::STRICT, )->willReturn($shortUrl); - $this->em->expects($this->once())->method('getRepository')->with(ShortUrl::class)->willReturn($this->repo); $this->expectException(ShortUrlNotFoundException::class); From 3ec24e3c67f8abf69e62d5e3227570a081cb9f6c Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 9 Nov 2024 09:43:55 +0100 Subject: [PATCH 36/80] Inject ShortUrlRepository in UrlShortener --- module/Core/config/dependencies.config.php | 1 + module/Core/src/ShortUrl/UrlShortener.php | 17 ++++++++--------- module/Core/test/ShortUrl/UrlShortenerTest.php | 9 +++++---- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 4afb28d5..7ae8ae27 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -138,6 +138,7 @@ return [ ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class, ShortUrl\Helper\ShortCodeUniquenessHelper::class, EventDispatcherInterface::class, + ShortUrl\Repository\ShortUrlRepository::class, ], Visit\VisitsTracker::class => [ 'em', diff --git a/module/Core/src/ShortUrl/UrlShortener.php b/module/Core/src/ShortUrl/UrlShortener.php index a0692e06..2a4d7571 100644 --- a/module/Core/src/ShortUrl/UrlShortener.php +++ b/module/Core/src/ShortUrl/UrlShortener.php @@ -17,14 +17,15 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\UrlShorteningResult; use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepositoryInterface; use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface; -class UrlShortener implements UrlShortenerInterface +readonly class UrlShortener implements UrlShortenerInterface { public function __construct( - private readonly ShortUrlTitleResolutionHelperInterface $titleResolutionHelper, - private readonly EntityManagerInterface $em, - private readonly ShortUrlRelationResolverInterface $relationResolver, - private readonly ShortCodeUniquenessHelperInterface $shortCodeHelper, - private readonly EventDispatcherInterface $eventDispatcher, + private ShortUrlTitleResolutionHelperInterface $titleResolutionHelper, + private EntityManagerInterface $em, + private ShortUrlRelationResolverInterface $relationResolver, + private ShortCodeUniquenessHelperInterface $shortCodeHelper, + private EventDispatcherInterface $eventDispatcher, + private ShortUrlRepositoryInterface $repo, ) { } @@ -70,9 +71,7 @@ class UrlShortener implements UrlShortenerInterface return null; } - /** @var ShortUrlRepositoryInterface $repo */ - $repo = $this->em->getRepository(ShortUrl::class); - return $repo->findOneMatching($creation); + return $this->repo->findOneMatching($creation); } private function verifyShortCodeUniqueness(ShortUrlCreation $meta, ShortUrl $shortUrlToBeCreated): void diff --git a/module/Core/test/ShortUrl/UrlShortenerTest.php b/module/Core/test/ShortUrl/UrlShortenerTest.php index b332afd2..a6cacc46 100644 --- a/module/Core/test/ShortUrl/UrlShortenerTest.php +++ b/module/Core/test/ShortUrl/UrlShortenerTest.php @@ -17,7 +17,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortCodeUniquenessHelperInterface; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlTitleResolutionHelperInterface; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; -use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepository; +use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepositoryInterface; use Shlinkio\Shlink\Core\ShortUrl\Resolver\SimpleShortUrlRelationResolver; use Shlinkio\Shlink\Core\ShortUrl\UrlShortener; @@ -28,6 +28,7 @@ class UrlShortenerTest extends TestCase private MockObject & ShortUrlTitleResolutionHelperInterface $titleResolutionHelper; private MockObject & ShortCodeUniquenessHelperInterface $shortCodeHelper; private MockObject & EventDispatcherInterface $dispatcher; + private MockObject & ShortUrlRepositoryInterface $repo; protected function setUp(): void { @@ -42,6 +43,7 @@ class UrlShortenerTest extends TestCase ); $this->dispatcher = $this->createMock(EventDispatcherInterface::class); + $this->repo = $this->createMock(ShortUrlRepositoryInterface::class); $this->urlShortener = new UrlShortener( $this->titleResolutionHelper, @@ -49,6 +51,7 @@ class UrlShortenerTest extends TestCase new SimpleShortUrlRelationResolver(), $this->shortCodeHelper, $this->dispatcher, + $this->repo, ); } @@ -102,9 +105,7 @@ class UrlShortenerTest extends TestCase #[Test, DataProvider('provideExistingShortUrls')] public function existingShortUrlIsReturnedWhenRequested(ShortUrlCreation $meta, ShortUrl $expected): void { - $repo = $this->createMock(ShortUrlRepository::class); - $repo->expects($this->once())->method('findOneMatching')->willReturn($expected); - $this->em->expects($this->once())->method('getRepository')->with(ShortUrl::class)->willReturn($repo); + $this->repo->expects($this->once())->method('findOneMatching')->willReturn($expected); $this->titleResolutionHelper->expects($this->never())->method('processTitle'); $this->shortCodeHelper->method('ensureShortCodeUniqueness')->willReturn(true); From fca389181966ccb8f5794d2e32cfd442855b250f Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 9 Nov 2024 09:47:47 +0100 Subject: [PATCH 37/80] Inject ShortUrlRepository in ShortCodeUniquenessHelper --- module/Core/config/dependencies.config.php | 5 ++++- .../Helper/ShortCodeUniquenessHelper.php | 16 ++++++---------- .../Helper/ShortCodeUniquenessHelperTest.php | 19 ++++++------------- 3 files changed, 16 insertions(+), 24 deletions(-) diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 7ae8ae27..ad3452e4 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -175,7 +175,10 @@ return [ Visit\Repository\VisitDeleterRepository::class, ShortUrl\ShortUrlResolver::class, ], - ShortUrl\Helper\ShortCodeUniquenessHelper::class => ['em', Config\Options\UrlShortenerOptions::class], + ShortUrl\Helper\ShortCodeUniquenessHelper::class => [ + ShortUrl\Repository\ShortUrlRepository::class, + Config\Options\UrlShortenerOptions::class, + ], Domain\DomainService::class => [ 'em', Config\Options\UrlShortenerOptions::class, diff --git a/module/Core/src/ShortUrl/Helper/ShortCodeUniquenessHelper.php b/module/Core/src/ShortUrl/Helper/ShortCodeUniquenessHelper.php index 7f863f6c..7c7f2a76 100644 --- a/module/Core/src/ShortUrl/Helper/ShortCodeUniquenessHelper.php +++ b/module/Core/src/ShortUrl/Helper/ShortCodeUniquenessHelper.php @@ -4,25 +4,21 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl\Helper; -use Doctrine\ORM\EntityManagerInterface; use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; -use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepository; +use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepositoryInterface; -class ShortCodeUniquenessHelper implements ShortCodeUniquenessHelperInterface +readonly class ShortCodeUniquenessHelper implements ShortCodeUniquenessHelperInterface { - public function __construct( - private readonly EntityManagerInterface $em, - private readonly UrlShortenerOptions $options, - ) { + public function __construct(private ShortUrlRepositoryInterface $repo, private UrlShortenerOptions $options) + { } public function ensureShortCodeUniqueness(ShortUrl $shortUrlToBeCreated, bool $hasCustomSlug): bool { - /** @var ShortUrlRepository $repo */ - $repo = $this->em->getRepository(ShortUrl::class); - $otherShortUrlsExist = $repo->shortCodeIsInUseWithLock(ShortUrlIdentifier::fromShortUrl($shortUrlToBeCreated)); + $identifier = ShortUrlIdentifier::fromShortUrl($shortUrlToBeCreated); + $otherShortUrlsExist = $this->repo->shortCodeIsInUseWithLock($identifier); if (! $otherShortUrlsExist) { return true; diff --git a/module/Core/test/ShortUrl/Helper/ShortCodeUniquenessHelperTest.php b/module/Core/test/ShortUrl/Helper/ShortCodeUniquenessHelperTest.php index f341585e..c08a95af 100644 --- a/module/Core/test/ShortUrl/Helper/ShortCodeUniquenessHelperTest.php +++ b/module/Core/test/ShortUrl/Helper/ShortCodeUniquenessHelperTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\ShortUrl\Helper; -use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; @@ -14,18 +13,18 @@ use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortCodeUniquenessHelper; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; -use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepository; +use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepositoryInterface; class ShortCodeUniquenessHelperTest extends TestCase { private ShortCodeUniquenessHelper $helper; - private MockObject & EntityManagerInterface $em; + private MockObject & ShortUrlRepositoryInterface $repo; private MockObject & ShortUrl $shortUrl; protected function setUp(): void { - $this->em = $this->createMock(EntityManagerInterface::class); - $this->helper = new ShortCodeUniquenessHelper($this->em, new UrlShortenerOptions()); + $this->repo = $this->createMock(ShortUrlRepositoryInterface::class); + $this->helper = new ShortCodeUniquenessHelper($this->repo, new UrlShortenerOptions()); $this->shortUrl = $this->createMock(ShortUrl::class); $this->shortUrl->method('getShortCode')->willReturn('abc123'); @@ -36,16 +35,12 @@ class ShortCodeUniquenessHelperTest extends TestCase { $callIndex = 0; $expectedCalls = 3; - $repo = $this->createMock(ShortUrlRepository::class); - $repo->expects($this->exactly($expectedCalls))->method('shortCodeIsInUseWithLock')->with( + $this->repo->expects($this->exactly($expectedCalls))->method('shortCodeIsInUseWithLock')->with( ShortUrlIdentifier::fromShortCodeAndDomain('abc123', $expectedAuthority), )->willReturnCallback(function () use (&$callIndex, $expectedCalls) { $callIndex++; return $callIndex < $expectedCalls; }); - $this->em->expects($this->exactly($expectedCalls))->method('getRepository')->with(ShortUrl::class)->willReturn( - $repo, - ); $this->shortUrl->method('getDomain')->willReturn($domain); $this->shortUrl->expects($this->exactly($expectedCalls - 1))->method('regenerateShortCode')->with(); @@ -63,11 +58,9 @@ class ShortCodeUniquenessHelperTest extends TestCase #[Test] public function inUseSlugReturnsError(): void { - $repo = $this->createMock(ShortUrlRepository::class); - $repo->expects($this->once())->method('shortCodeIsInUseWithLock')->with( + $this->repo->expects($this->once())->method('shortCodeIsInUseWithLock')->with( ShortUrlIdentifier::fromShortCodeAndDomain('abc123'), )->willReturn(true); - $this->em->expects($this->once())->method('getRepository')->with(ShortUrl::class)->willReturn($repo); $this->shortUrl->method('getDomain')->willReturn(null); $this->shortUrl->expects($this->never())->method('regenerateShortCode'); From 72f1e243b506e91f332084ca2c2ea47814f2b729 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 9 Nov 2024 09:55:51 +0100 Subject: [PATCH 38/80] Make classes readonly when possible --- module/CLI/src/Input/EndDateOption.php | 2 +- module/CLI/src/Input/ShortUrlDataInput.php | 2 +- module/CLI/src/Input/ShortUrlIdentifierInput.php | 2 +- module/CLI/src/Input/StartDateOption.php | 2 +- module/Core/src/Crawling/CrawlingHelper.php | 4 ++-- module/Core/src/Domain/Model/DomainItem.php | 8 ++++---- module/Core/src/ErrorHandler/Model/NotFoundType.php | 4 ++-- .../src/EventDispatcher/LocateUnlocatedVisits.php | 6 +++--- module/Core/src/EventDispatcher/UpdateGeoLiteDb.php | 8 ++++---- .../Middleware/ExtraPathRedirectMiddleware.php | 12 ++++++------ .../Core/src/ShortUrl/Model/UrlShorteningResult.php | 6 +++--- module/Core/src/ShortUrl/ShortUrlVisitsDeleter.php | 6 +++--- module/Core/src/Util/RedirectResponseHelper.php | 4 ++-- module/Core/src/Visit/Geolocation/VisitLocator.php | 6 +++--- .../src/Visit/Geolocation/VisitToLocationHelper.php | 4 ++-- module/Core/src/Visit/VisitsDeleter.php | 4 ++-- 16 files changed, 40 insertions(+), 40 deletions(-) diff --git a/module/CLI/src/Input/EndDateOption.php b/module/CLI/src/Input/EndDateOption.php index f2073397..a38b9b32 100644 --- a/module/CLI/src/Input/EndDateOption.php +++ b/module/CLI/src/Input/EndDateOption.php @@ -11,7 +11,7 @@ use Symfony\Component\Console\Output\OutputInterface; use function sprintf; -readonly final class EndDateOption +final readonly class EndDateOption { private DateOption $dateOption; diff --git a/module/CLI/src/Input/ShortUrlDataInput.php b/module/CLI/src/Input/ShortUrlDataInput.php index 2d3bf91e..1ff1de3f 100644 --- a/module/CLI/src/Input/ShortUrlDataInput.php +++ b/module/CLI/src/Input/ShortUrlDataInput.php @@ -18,7 +18,7 @@ use function array_unique; use function Shlinkio\Shlink\Core\ArrayUtils\flatten; use function Shlinkio\Shlink\Core\splitByComma; -readonly final class ShortUrlDataInput +final readonly class ShortUrlDataInput { public function __construct(Command $command, private bool $longUrlAsOption = false) { diff --git a/module/CLI/src/Input/ShortUrlIdentifierInput.php b/module/CLI/src/Input/ShortUrlIdentifierInput.php index def03f74..46ac79da 100644 --- a/module/CLI/src/Input/ShortUrlIdentifierInput.php +++ b/module/CLI/src/Input/ShortUrlIdentifierInput.php @@ -10,7 +10,7 @@ use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; -readonly final class ShortUrlIdentifierInput +final readonly class ShortUrlIdentifierInput { public function __construct(Command $command, string $shortCodeDesc, string $domainDesc) { diff --git a/module/CLI/src/Input/StartDateOption.php b/module/CLI/src/Input/StartDateOption.php index eaef301f..453b31a2 100644 --- a/module/CLI/src/Input/StartDateOption.php +++ b/module/CLI/src/Input/StartDateOption.php @@ -11,7 +11,7 @@ use Symfony\Component\Console\Output\OutputInterface; use function sprintf; -readonly final class StartDateOption +final readonly class StartDateOption { private DateOption $dateOption; diff --git a/module/Core/src/Crawling/CrawlingHelper.php b/module/Core/src/Crawling/CrawlingHelper.php index 958cb96e..12c0e546 100644 --- a/module/Core/src/Crawling/CrawlingHelper.php +++ b/module/Core/src/Crawling/CrawlingHelper.php @@ -6,9 +6,9 @@ namespace Shlinkio\Shlink\Core\Crawling; use Shlinkio\Shlink\Core\ShortUrl\Repository\CrawlableShortCodesQueryInterface; -class CrawlingHelper implements CrawlingHelperInterface +readonly class CrawlingHelper implements CrawlingHelperInterface { - public function __construct(private readonly CrawlableShortCodesQueryInterface $query) + public function __construct(private CrawlableShortCodesQueryInterface $query) { } diff --git a/module/Core/src/Domain/Model/DomainItem.php b/module/Core/src/Domain/Model/DomainItem.php index 53f2b6f7..6352e924 100644 --- a/module/Core/src/Domain/Model/DomainItem.php +++ b/module/Core/src/Domain/Model/DomainItem.php @@ -9,12 +9,12 @@ use Shlinkio\Shlink\Core\Config\NotFoundRedirectConfigInterface; use Shlinkio\Shlink\Core\Config\NotFoundRedirects; use Shlinkio\Shlink\Core\Domain\Entity\Domain; -final class DomainItem implements JsonSerializable +final readonly class DomainItem implements JsonSerializable { private function __construct( - private readonly string $authority, - public readonly NotFoundRedirectConfigInterface $notFoundRedirectConfig, - public readonly bool $isDefault, + private string $authority, + public NotFoundRedirectConfigInterface $notFoundRedirectConfig, + public bool $isDefault, ) { } diff --git a/module/Core/src/ErrorHandler/Model/NotFoundType.php b/module/Core/src/ErrorHandler/Model/NotFoundType.php index de0c5460..99f71f8b 100644 --- a/module/Core/src/ErrorHandler/Model/NotFoundType.php +++ b/module/Core/src/ErrorHandler/Model/NotFoundType.php @@ -11,9 +11,9 @@ use Shlinkio\Shlink\Core\Visit\Model\VisitType; use function rtrim; -class NotFoundType +readonly class NotFoundType { - private function __construct(private readonly VisitType|null $type) + private function __construct(private VisitType|null $type) { } diff --git a/module/Core/src/EventDispatcher/LocateUnlocatedVisits.php b/module/Core/src/EventDispatcher/LocateUnlocatedVisits.php index 1a51e949..3c60515b 100644 --- a/module/Core/src/EventDispatcher/LocateUnlocatedVisits.php +++ b/module/Core/src/EventDispatcher/LocateUnlocatedVisits.php @@ -13,11 +13,11 @@ use Shlinkio\Shlink\Core\Visit\Geolocation\VisitLocatorInterface; use Shlinkio\Shlink\Core\Visit\Geolocation\VisitToLocationHelperInterface; use Shlinkio\Shlink\IpGeolocation\Model\Location; -class LocateUnlocatedVisits implements VisitGeolocationHelperInterface +readonly class LocateUnlocatedVisits implements VisitGeolocationHelperInterface { public function __construct( - private readonly VisitLocatorInterface $locator, - private readonly VisitToLocationHelperInterface $visitToLocation, + private VisitLocatorInterface $locator, + private VisitToLocationHelperInterface $visitToLocation, ) { } diff --git a/module/Core/src/EventDispatcher/UpdateGeoLiteDb.php b/module/Core/src/EventDispatcher/UpdateGeoLiteDb.php index f19378ea..4e4720c5 100644 --- a/module/Core/src/EventDispatcher/UpdateGeoLiteDb.php +++ b/module/Core/src/EventDispatcher/UpdateGeoLiteDb.php @@ -13,12 +13,12 @@ use Throwable; use function sprintf; -class UpdateGeoLiteDb +readonly class UpdateGeoLiteDb { public function __construct( - private readonly GeolocationDbUpdaterInterface $dbUpdater, - private readonly LoggerInterface $logger, - private readonly EventDispatcherInterface $eventDispatcher, + private GeolocationDbUpdaterInterface $dbUpdater, + private LoggerInterface $logger, + private EventDispatcherInterface $eventDispatcher, ) { } diff --git a/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php b/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php index 7c868907..4a02f6e9 100644 --- a/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php +++ b/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php @@ -25,14 +25,14 @@ use function implode; use function sprintf; use function trim; -class ExtraPathRedirectMiddleware implements MiddlewareInterface +readonly class ExtraPathRedirectMiddleware implements MiddlewareInterface { public function __construct( - private readonly ShortUrlResolverInterface $resolver, - private readonly RequestTrackerInterface $requestTracker, - private readonly ShortUrlRedirectionBuilderInterface $redirectionBuilder, - private readonly RedirectResponseHelperInterface $redirectResponseHelper, - private readonly UrlShortenerOptions $urlShortenerOptions, + private ShortUrlResolverInterface $resolver, + private RequestTrackerInterface $requestTracker, + private ShortUrlRedirectionBuilderInterface $redirectionBuilder, + private RedirectResponseHelperInterface $redirectResponseHelper, + private UrlShortenerOptions $urlShortenerOptions, ) { } diff --git a/module/Core/src/ShortUrl/Model/UrlShorteningResult.php b/module/Core/src/ShortUrl/Model/UrlShorteningResult.php index 6bfd91bc..a710b63a 100644 --- a/module/Core/src/ShortUrl/Model/UrlShorteningResult.php +++ b/module/Core/src/ShortUrl/Model/UrlShorteningResult.php @@ -7,11 +7,11 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Model; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Throwable; -final class UrlShorteningResult +final readonly class UrlShorteningResult { private function __construct( - public readonly ShortUrl $shortUrl, - private readonly Throwable|null $errorOnEventDispatching, + public ShortUrl $shortUrl, + private Throwable|null $errorOnEventDispatching, ) { } diff --git a/module/Core/src/ShortUrl/ShortUrlVisitsDeleter.php b/module/Core/src/ShortUrl/ShortUrlVisitsDeleter.php index e8a07654..eec122a2 100644 --- a/module/Core/src/ShortUrl/ShortUrlVisitsDeleter.php +++ b/module/Core/src/ShortUrl/ShortUrlVisitsDeleter.php @@ -10,11 +10,11 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Visit\Repository\VisitDeleterRepositoryInterface; use Shlinkio\Shlink\Rest\Entity\ApiKey; -class ShortUrlVisitsDeleter implements ShortUrlVisitsDeleterInterface +readonly class ShortUrlVisitsDeleter implements ShortUrlVisitsDeleterInterface { public function __construct( - private readonly VisitDeleterRepositoryInterface $repository, - private readonly ShortUrlResolverInterface $resolver, + private VisitDeleterRepositoryInterface $repository, + private ShortUrlResolverInterface $resolver, ) { } diff --git a/module/Core/src/Util/RedirectResponseHelper.php b/module/Core/src/Util/RedirectResponseHelper.php index edb04b8e..4c4fdd21 100644 --- a/module/Core/src/Util/RedirectResponseHelper.php +++ b/module/Core/src/Util/RedirectResponseHelper.php @@ -10,9 +10,9 @@ use Shlinkio\Shlink\Core\Config\Options\RedirectOptions; use function sprintf; -class RedirectResponseHelper implements RedirectResponseHelperInterface +readonly class RedirectResponseHelper implements RedirectResponseHelperInterface { - public function __construct(private readonly RedirectOptions $options) + public function __construct(private RedirectOptions $options) { } diff --git a/module/Core/src/Visit/Geolocation/VisitLocator.php b/module/Core/src/Visit/Geolocation/VisitLocator.php index 63cb6137..f3aba193 100644 --- a/module/Core/src/Visit/Geolocation/VisitLocator.php +++ b/module/Core/src/Visit/Geolocation/VisitLocator.php @@ -11,11 +11,11 @@ use Shlinkio\Shlink\Core\Visit\Entity\VisitLocation; use Shlinkio\Shlink\Core\Visit\Repository\VisitIterationRepositoryInterface; use Shlinkio\Shlink\IpGeolocation\Model\Location; -class VisitLocator implements VisitLocatorInterface +readonly class VisitLocator implements VisitLocatorInterface { public function __construct( - private readonly EntityManagerInterface $em, - private readonly VisitIterationRepositoryInterface $repo, + private EntityManagerInterface $em, + private VisitIterationRepositoryInterface $repo, ) { } diff --git a/module/Core/src/Visit/Geolocation/VisitToLocationHelper.php b/module/Core/src/Visit/Geolocation/VisitToLocationHelper.php index 9d614a7b..b444e592 100644 --- a/module/Core/src/Visit/Geolocation/VisitToLocationHelper.php +++ b/module/Core/src/Visit/Geolocation/VisitToLocationHelper.php @@ -11,9 +11,9 @@ use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException; use Shlinkio\Shlink\IpGeolocation\Model\Location; use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; -class VisitToLocationHelper implements VisitToLocationHelperInterface +readonly class VisitToLocationHelper implements VisitToLocationHelperInterface { - public function __construct(private readonly IpLocationResolverInterface $ipLocationResolver) + public function __construct(private IpLocationResolverInterface $ipLocationResolver) { } diff --git a/module/Core/src/Visit/VisitsDeleter.php b/module/Core/src/Visit/VisitsDeleter.php index fb0f231a..42ca0ffa 100644 --- a/module/Core/src/Visit/VisitsDeleter.php +++ b/module/Core/src/Visit/VisitsDeleter.php @@ -9,9 +9,9 @@ use Shlinkio\Shlink\Core\Visit\Repository\VisitDeleterRepositoryInterface; use Shlinkio\Shlink\Rest\ApiKey\Role; use Shlinkio\Shlink\Rest\Entity\ApiKey; -class VisitsDeleter implements VisitsDeleterInterface +readonly class VisitsDeleter implements VisitsDeleterInterface { - public function __construct(private readonly VisitDeleterRepositoryInterface $repository) + public function __construct(private VisitDeleterRepositoryInterface $repository) { } From 95685d958d068f77cb966e47268b1223a8fc1048 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 9 Nov 2024 11:02:10 +0100 Subject: [PATCH 39/80] Update to latest test utils --- composer.json | 2 +- .../ShortUrl/Repository/CrawlableShortCodesQueryTest.php | 3 +-- .../Repository/DeleteExpiredShortUrlsRepositoryTest.php | 3 +-- .../test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php | 2 +- .../test-db/Visit/Repository/VisitDeleterRepositoryTest.php | 3 +-- .../test-db/Visit/Repository/VisitIterationRepositoryTest.php | 3 +-- 6 files changed, 6 insertions(+), 10 deletions(-) diff --git a/composer.json b/composer.json index 1100f099..84f626b7 100644 --- a/composer.json +++ b/composer.json @@ -73,7 +73,7 @@ "phpunit/phpunit": "^11.4", "roave/security-advisories": "dev-master", "shlinkio/php-coding-standard": "~2.4.0", - "shlinkio/shlink-test-utils": "^4.1.1", + "shlinkio/shlink-test-utils": "^4.2", "symfony/var-dumper": "^7.1", "veewee/composer-run-parallel": "^1.4" }, diff --git a/module/Core/test-db/ShortUrl/Repository/CrawlableShortCodesQueryTest.php b/module/Core/test-db/ShortUrl/Repository/CrawlableShortCodesQueryTest.php index d630520b..60955dd1 100644 --- a/module/Core/test-db/ShortUrl/Repository/CrawlableShortCodesQueryTest.php +++ b/module/Core/test-db/ShortUrl/Repository/CrawlableShortCodesQueryTest.php @@ -16,8 +16,7 @@ class CrawlableShortCodesQueryTest extends DatabaseTestCase protected function setUp(): void { - $em = $this->getEntityManager(); - $this->query = new CrawlableShortCodesQuery($em, $em->getClassMetadata(ShortUrl::class)); + $this->query = $this->createRepository(ShortUrl::class, CrawlableShortCodesQuery::class); } #[Test] diff --git a/module/Core/test-db/ShortUrl/Repository/DeleteExpiredShortUrlsRepositoryTest.php b/module/Core/test-db/ShortUrl/Repository/DeleteExpiredShortUrlsRepositoryTest.php index d90ad256..1751aac9 100644 --- a/module/Core/test-db/ShortUrl/Repository/DeleteExpiredShortUrlsRepositoryTest.php +++ b/module/Core/test-db/ShortUrl/Repository/DeleteExpiredShortUrlsRepositoryTest.php @@ -22,8 +22,7 @@ class DeleteExpiredShortUrlsRepositoryTest extends DatabaseTestCase protected function setUp(): void { - $em = $this->getEntityManager(); - $this->repository = new ExpiredShortUrlsRepository($em, $em->getClassMetadata(ShortUrl::class)); + $this->repository = $this->createRepository(ShortUrl::class, ExpiredShortUrlsRepository::class); } #[Test] diff --git a/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php b/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php index 05109365..26d2dff5 100644 --- a/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php +++ b/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php @@ -37,7 +37,7 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase protected function setUp(): void { $em = $this->getEntityManager(); - $this->repo = new ShortUrlListRepository($em, $em->getClassMetadata(ShortUrl::class)); + $this->repo = $this->createRepository(ShortUrl::class, ShortUrlListRepository::class); $this->relationResolver = new PersistenceShortUrlRelationResolver($em); } diff --git a/module/Core/test-db/Visit/Repository/VisitDeleterRepositoryTest.php b/module/Core/test-db/Visit/Repository/VisitDeleterRepositoryTest.php index 62aa89e7..6529c6a9 100644 --- a/module/Core/test-db/Visit/Repository/VisitDeleterRepositoryTest.php +++ b/module/Core/test-db/Visit/Repository/VisitDeleterRepositoryTest.php @@ -20,8 +20,7 @@ class VisitDeleterRepositoryTest extends DatabaseTestCase protected function setUp(): void { - $em = $this->getEntityManager(); - $this->repo = new VisitDeleterRepository($em, $em->getClassMetadata(Visit::class)); + $this->repo = $this->createRepository(Visit::class, VisitDeleterRepository::class); } #[Test] diff --git a/module/Core/test-db/Visit/Repository/VisitIterationRepositoryTest.php b/module/Core/test-db/Visit/Repository/VisitIterationRepositoryTest.php index 6d3d4b39..ee5843d5 100644 --- a/module/Core/test-db/Visit/Repository/VisitIterationRepositoryTest.php +++ b/module/Core/test-db/Visit/Repository/VisitIterationRepositoryTest.php @@ -25,8 +25,7 @@ class VisitIterationRepositoryTest extends DatabaseTestCase protected function setUp(): void { - $em = $this->getEntityManager(); - $this->repo = new VisitIterationRepository($em, $em->getClassMetadata(Visit::class)); + $this->repo = $this->createRepository(Visit::class, VisitIterationRepository::class); } #[Test, DataProvider('provideBlockSize')] From d228b88e517708b03e60fe1d357f25a8115d980b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 9 Nov 2024 11:09:34 +0100 Subject: [PATCH 40/80] Lock transaction to avoid race conditions when renaming an API key --- .../ApiKey/Repository/ApiKeyRepository.php | 21 +++++++- .../Repository/ApiKeyRepositoryInterface.php | 7 ++- module/Rest/src/Service/ApiKeyService.php | 48 +++++++++---------- .../Repository/ApiKeyRepositoryTest.php | 11 +++++ .../Rest/test/Service/ApiKeyServiceTest.php | 18 +++---- 5 files changed, 69 insertions(+), 36 deletions(-) diff --git a/module/Rest/src/ApiKey/Repository/ApiKeyRepository.php b/module/Rest/src/ApiKey/Repository/ApiKeyRepository.php index b4523371..e1fbf3a6 100644 --- a/module/Rest/src/ApiKey/Repository/ApiKeyRepository.php +++ b/module/Rest/src/ApiKey/Repository/ApiKeyRepository.php @@ -15,7 +15,7 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class ApiKeyRepository extends EntitySpecificationRepository implements ApiKeyRepositoryInterface { /** - * Will create provided API key with admin permissions, only if no other API keys exist yet + * @inheritDoc */ public function createInitialApiKey(string $apiKey): ApiKey|null { @@ -41,4 +41,23 @@ class ApiKeyRepository extends EntitySpecificationRepository implements ApiKeyRe return $initialApiKey; }); } + + /** + * @inheritDoc + */ + public function nameExists(string $name): bool + { + $qb = $this->getEntityManager()->createQueryBuilder(); + $qb->select('a.id') + ->from(ApiKey::class, 'a') + ->where($qb->expr()->eq('a.name', ':name')) + ->setParameter('name', $name) + ->setMaxResults(1); + + // Lock for update, to avoid a race condition that inserts a duplicate name after we have checked if one existed + $query = $qb->getQuery(); + $query->setLockMode(LockMode::PESSIMISTIC_WRITE); + + return $query->getOneOrNullResult() !== null; + } } diff --git a/module/Rest/src/ApiKey/Repository/ApiKeyRepositoryInterface.php b/module/Rest/src/ApiKey/Repository/ApiKeyRepositoryInterface.php index 0f81dc10..32ada38a 100644 --- a/module/Rest/src/ApiKey/Repository/ApiKeyRepositoryInterface.php +++ b/module/Rest/src/ApiKey/Repository/ApiKeyRepositoryInterface.php @@ -14,7 +14,12 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; interface ApiKeyRepositoryInterface extends EntityRepositoryInterface, EntitySpecificationRepositoryInterface { /** - * Will create provided API key only if there's no API keys yet + * Will create provided API key with admin permissions, only if no other API keys exist yet */ public function createInitialApiKey(string $apiKey): ApiKey|null; + + /** + * Checks whether an API key with provided name exists or not + */ + public function nameExists(string $name): bool; } diff --git a/module/Rest/src/Service/ApiKeyService.php b/module/Rest/src/Service/ApiKeyService.php index f517dde5..19140534 100644 --- a/module/Rest/src/Service/ApiKeyService.php +++ b/module/Rest/src/Service/ApiKeyService.php @@ -21,18 +21,20 @@ readonly class ApiKeyService implements ApiKeyServiceInterface public function create(ApiKeyMeta $apiKeyMeta): ApiKey { - // TODO If name is auto-generated, do not throw. Instead, re-generate a new key - $apiKey = ApiKey::fromMeta($apiKeyMeta); - if ($this->existsWithName($apiKey->name)) { - throw new InvalidArgumentException( - sprintf('Another API key with name "%s" already exists', $apiKeyMeta->name), - ); - } + return $this->em->wrapInTransaction(function () use ($apiKeyMeta) { + $apiKey = ApiKey::fromMeta($apiKeyMeta); + // TODO If name is auto-generated, do not throw. Instead, re-generate a new key + if ($this->repo->nameExists($apiKey->name)) { + throw new InvalidArgumentException( + sprintf('Another API key with name "%s" already exists', $apiKeyMeta->name), + ); + } - $this->em->persist($apiKey); - $this->em->flush(); + $this->em->persist($apiKey); + $this->em->flush(); - return $apiKey; + return $apiKey; + }); } public function createInitial(string $key): ApiKey|null @@ -85,9 +87,6 @@ readonly class ApiKeyService implements ApiKeyServiceInterface /** * @inheritDoc - * @todo This method should be transactional and to a SELECT ... FROM UPDATE when checking if the new name exists, - * to avoid a race condition where the method is called twice in parallel for a new name that doesn't exist, - * causing two API keys to end up with the same name. */ public function renameApiKey(Renaming $apiKeyRenaming): ApiKey { @@ -102,25 +101,22 @@ readonly class ApiKeyService implements ApiKeyServiceInterface return $apiKey; } - if ($this->existsWithName($apiKeyRenaming->newName)) { - throw new InvalidArgumentException( - sprintf('Another API key with name "%s" already exists', $apiKeyRenaming->newName), - ); - } + return $this->em->wrapInTransaction(function () use ($apiKeyRenaming, $apiKey) { + if ($this->repo->nameExists($apiKeyRenaming->newName)) { + throw new InvalidArgumentException( + sprintf('Another API key with name "%s" already exists', $apiKeyRenaming->newName), + ); + } - $apiKey->name = $apiKeyRenaming->newName; - $this->em->flush(); + $apiKey->name = $apiKeyRenaming->newName; + $this->em->flush(); - return $apiKey; + return $apiKey; + }); } private function findByKey(string $key): ApiKey|null { return $this->repo->findOneBy(['key' => ApiKey::hashKey($key)]); } - - private function existsWithName(string $apiKeyName): bool - { - return $this->repo->count(['name' => $apiKeyName]) > 0; - } } diff --git a/module/Rest/test-db/ApiKey/Repository/ApiKeyRepositoryTest.php b/module/Rest/test-db/ApiKey/Repository/ApiKeyRepositoryTest.php index 62d52de6..d0f6157d 100644 --- a/module/Rest/test-db/ApiKey/Repository/ApiKeyRepositoryTest.php +++ b/module/Rest/test-db/ApiKey/Repository/ApiKeyRepositoryTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace ShlinkioDbTest\Shlink\Rest\ApiKey\Repository; use PHPUnit\Framework\Attributes\Test; +use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\ApiKey\Repository\ApiKeyRepository; use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase; @@ -29,4 +30,14 @@ class ApiKeyRepositoryTest extends DatabaseTestCase self::assertCount(1, $this->repo->findAll()); self::assertCount(0, $this->repo->findBy(['key' => ApiKey::hashKey('another_one')])); } + + #[Test] + public function nameExistsReturnsExpectedResult(): void + { + $this->getEntityManager()->persist(ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'foo'))); + $this->getEntityManager()->flush(); + + self::assertTrue($this->repo->nameExists('foo')); + self::assertFalse($this->repo->nameExists('bar')); + } } diff --git a/module/Rest/test/Service/ApiKeyServiceTest.php b/module/Rest/test/Service/ApiKeyServiceTest.php index adecfbd9..2b3d4f96 100644 --- a/module/Rest/test/Service/ApiKeyServiceTest.php +++ b/module/Rest/test/Service/ApiKeyServiceTest.php @@ -30,6 +30,8 @@ class ApiKeyServiceTest extends TestCase protected function setUp(): void { $this->em = $this->createMock(EntityManager::class); + $this->em->method('wrapInTransaction')->willReturnCallback(fn (callable $callback) => $callback()); + $this->repo = $this->createMock(ApiKeyRepositoryInterface::class); $this->service = new ApiKeyService($this->em, $this->repo); } @@ -40,9 +42,9 @@ class ApiKeyServiceTest extends TestCase #[Test, DataProvider('provideCreationDate')] public function apiKeyIsProperlyCreated(Chronos|null $date, string|null $name, array $roles): void { - $this->repo->expects($this->once())->method('count')->with( - ! empty($name) ? ['name' => $name] : $this->isType('array'), - )->willReturn(0); + $this->repo->expects($this->once())->method('nameExists')->with( + ! empty($name) ? $name : $this->isType('string'), + )->willReturn(false); $this->em->expects($this->once())->method('flush'); $this->em->expects($this->once())->method('persist')->with($this->isInstanceOf(ApiKey::class)); @@ -78,7 +80,7 @@ class ApiKeyServiceTest extends TestCase #[Test] public function exceptionIsThrownWhileCreatingIfNameIsInUse(): void { - $this->repo->expects($this->once())->method('count')->with(['name' => 'the_name'])->willReturn(1); + $this->repo->expects($this->once())->method('nameExists')->with('the_name')->willReturn(true); $this->em->expects($this->never())->method('flush'); $this->em->expects($this->never())->method('persist'); @@ -200,7 +202,7 @@ class ApiKeyServiceTest extends TestCase $renaming = Renaming::fromNames(oldName: 'old', newName: 'new'); $this->repo->expects($this->once())->method('findOneBy')->with(['name' => 'old'])->willReturn(null); - $this->repo->expects($this->never())->method('count'); + $this->repo->expects($this->never())->method('nameExists'); $this->em->expects($this->never())->method('flush'); $this->expectException(InvalidArgumentException::class); @@ -216,7 +218,7 @@ class ApiKeyServiceTest extends TestCase $apiKey = ApiKey::create(); $this->repo->expects($this->once())->method('findOneBy')->with(['name' => 'same_value'])->willReturn($apiKey); - $this->repo->expects($this->never())->method('count'); + $this->repo->expects($this->never())->method('nameExists'); $this->em->expects($this->never())->method('flush'); $result = $this->service->renameApiKey($renaming); @@ -231,7 +233,7 @@ class ApiKeyServiceTest extends TestCase $apiKey = ApiKey::create(); $this->repo->expects($this->once())->method('findOneBy')->with(['name' => 'old'])->willReturn($apiKey); - $this->repo->expects($this->once())->method('count')->with(['name' => 'new'])->willReturn(1); + $this->repo->expects($this->once())->method('nameExists')->with('new')->willReturn(true); $this->em->expects($this->never())->method('flush'); $this->expectException(InvalidArgumentException::class); @@ -247,7 +249,7 @@ class ApiKeyServiceTest extends TestCase $apiKey = ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'old')); $this->repo->expects($this->once())->method('findOneBy')->with(['name' => 'old'])->willReturn($apiKey); - $this->repo->expects($this->once())->method('count')->with(['name' => 'new'])->willReturn(0); + $this->repo->expects($this->once())->method('nameExists')->with('new')->willReturn(false); $this->em->expects($this->once())->method('flush'); $result = $this->service->renameApiKey($renaming); From 3c6f12aec614d658409a0c2796786c360e4ef01a Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 9 Nov 2024 12:07:07 +0100 Subject: [PATCH 41/80] Ensure auto-generated name API keys do not throw duplicated name --- module/Rest/src/ApiKey/Model/ApiKeyMeta.php | 7 ++-- module/Rest/src/Entity/ApiKey.php | 2 +- module/Rest/src/Service/ApiKeyService.php | 35 ++++++++++++++----- .../src/Service/ApiKeyServiceInterface.php | 3 ++ .../Rest/test/Service/ApiKeyServiceTest.php | 18 +++++++++- 5 files changed, 53 insertions(+), 12 deletions(-) diff --git a/module/Rest/src/ApiKey/Model/ApiKeyMeta.php b/module/Rest/src/ApiKey/Model/ApiKeyMeta.php index 66d7d889..ae1b189c 100644 --- a/module/Rest/src/ApiKey/Model/ApiKeyMeta.php +++ b/module/Rest/src/ApiKey/Model/ApiKeyMeta.php @@ -18,12 +18,13 @@ final readonly class ApiKeyMeta private function __construct( public string $key, public string $name, + public bool $isNameAutoGenerated, public Chronos|null $expirationDate, public iterable $roleDefinitions, ) { } - public static function empty(): self + public static function create(): self { return self::fromParams(); } @@ -38,9 +39,10 @@ final readonly class ApiKeyMeta iterable $roleDefinitions = [], ): self { $resolvedKey = $key ?? Uuid::uuid4()->toString(); + $isNameAutoGenerated = empty($name); // If a name was not provided, fall back to the key - if (empty($name)) { + if ($isNameAutoGenerated) { // If the key was auto-generated, fall back to a redacted version of the UUID, otherwise simply use the // plain key as fallback name $name = $key === null @@ -51,6 +53,7 @@ final readonly class ApiKeyMeta return new self( key: $resolvedKey, name: $name, + isNameAutoGenerated: $isNameAutoGenerated, expirationDate: $expirationDate, roleDefinitions: $roleDefinitions, ); diff --git a/module/Rest/src/Entity/ApiKey.php b/module/Rest/src/Entity/ApiKey.php index c9cdd3a6..63bb6fc9 100644 --- a/module/Rest/src/Entity/ApiKey.php +++ b/module/Rest/src/Entity/ApiKey.php @@ -33,7 +33,7 @@ class ApiKey extends AbstractEntity public static function create(): ApiKey { - return self::fromMeta(ApiKeyMeta::empty()); + return self::fromMeta(ApiKeyMeta::create()); } public static function fromMeta(ApiKeyMeta $meta): self diff --git a/module/Rest/src/Service/ApiKeyService.php b/module/Rest/src/Service/ApiKeyService.php index 19140534..09d1bb76 100644 --- a/module/Rest/src/Service/ApiKeyService.php +++ b/module/Rest/src/Service/ApiKeyService.php @@ -19,17 +19,13 @@ readonly class ApiKeyService implements ApiKeyServiceInterface { } + /** + * @inheritDoc + */ public function create(ApiKeyMeta $apiKeyMeta): ApiKey { return $this->em->wrapInTransaction(function () use ($apiKeyMeta) { - $apiKey = ApiKey::fromMeta($apiKeyMeta); - // TODO If name is auto-generated, do not throw. Instead, re-generate a new key - if ($this->repo->nameExists($apiKey->name)) { - throw new InvalidArgumentException( - sprintf('Another API key with name "%s" already exists', $apiKeyMeta->name), - ); - } - + $apiKey = ApiKey::fromMeta($this->ensureUniqueName($apiKeyMeta)); $this->em->persist($apiKey); $this->em->flush(); @@ -37,6 +33,29 @@ readonly class ApiKeyService implements ApiKeyServiceInterface }); } + /** + * Given an ApiKeyMeta object, it returns another instance ensuring the name is unique. + * - If the name was auto-generated, it continues re-trying until a unique name is resolved. + * - If the name was explicitly provided, it throws in case of name conflict. + */ + private function ensureUniqueName(ApiKeyMeta $apiKeyMeta): ApiKeyMeta + { + if (! $this->repo->nameExists($apiKeyMeta->name)) { + return $apiKeyMeta; + } + + if (! $apiKeyMeta->isNameAutoGenerated) { + throw new InvalidArgumentException( + sprintf('Another API key with name "%s" already exists', $apiKeyMeta->name), + ); + } + + return $this->ensureUniqueName(ApiKeyMeta::fromParams( + expirationDate: $apiKeyMeta->expirationDate, + roleDefinitions: $apiKeyMeta->roleDefinitions, + )); + } + public function createInitial(string $key): ApiKey|null { return $this->repo->createInitialApiKey($key); diff --git a/module/Rest/src/Service/ApiKeyServiceInterface.php b/module/Rest/src/Service/ApiKeyServiceInterface.php index c42505b7..be7b9191 100644 --- a/module/Rest/src/Service/ApiKeyServiceInterface.php +++ b/module/Rest/src/Service/ApiKeyServiceInterface.php @@ -11,6 +11,9 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; interface ApiKeyServiceInterface { + /** + * @throws InvalidArgumentException + */ public function create(ApiKeyMeta $apiKeyMeta): ApiKey; public function createInitial(string $key): ApiKey|null; diff --git a/module/Rest/test/Service/ApiKeyServiceTest.php b/module/Rest/test/Service/ApiKeyServiceTest.php index 2b3d4f96..ee33e109 100644 --- a/module/Rest/test/Service/ApiKeyServiceTest.php +++ b/module/Rest/test/Service/ApiKeyServiceTest.php @@ -78,7 +78,23 @@ class ApiKeyServiceTest extends TestCase } #[Test] - public function exceptionIsThrownWhileCreatingIfNameIsInUse(): void + public function autoGeneratedNameIsRegeneratedIfAlreadyExists(): void + { + $callCount = 0; + $this->repo->expects($this->exactly(3))->method('nameExists')->with( + $this->isType('string'), + )->willReturnCallback(function () use (&$callCount): bool { + $callCount++; + return $callCount < 3; + }); + $this->em->expects($this->once())->method('flush'); + $this->em->expects($this->once())->method('persist')->with($this->isInstanceOf(ApiKey::class)); + + $this->service->create(ApiKeyMeta::create()); + } + + #[Test] + public function exceptionIsThrownWhileCreatingIfExplicitlyProvidedNameIsInUse(): void { $this->repo->expects($this->once())->method('nameExists')->with('the_name')->willReturn(true); $this->em->expects($this->never())->method('flush'); From a5a98bd57852dd371742138a58ad320eac3bfb33 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 11 Nov 2024 08:51:55 +0100 Subject: [PATCH 42/80] Update VisitsTracker so that its methods return the new Visit instance, if any --- .../Command/Visit/LocateVisitsCommandTest.php | 2 +- module/Core/src/Visit/Model/Visitor.php | 2 +- module/Core/src/Visit/VisitsTracker.php | 38 +++++++++---------- .../Core/src/Visit/VisitsTrackerInterface.php | 9 +++-- .../DeleteExpiredShortUrlsRepositoryTest.php | 2 +- .../Repository/ShortUrlListRepositoryTest.php | 8 ++-- .../Tag/Repository/TagRepositoryTest.php | 6 +-- .../Listener/OrphanVisitsCountTrackerTest.php | 4 +- .../ShortUrlVisitsCountTrackerTest.php | 4 +- .../Repository/VisitDeleterRepositoryTest.php | 16 ++++---- .../VisitIterationRepositoryTest.php | 2 +- .../Visit/Repository/VisitRepositoryTest.php | 22 +++++------ .../LocateUnlocatedVisitsTest.php | 2 +- .../Matomo/SendVisitToMatomoTest.php | 2 +- .../Mercure/NotifyVisitToMercureTest.php | 6 +-- .../PublishingUpdatesGeneratorTest.php | 4 +- .../RabbitMq/NotifyVisitToRabbitMqTest.php | 8 ++-- .../RedisPubSub/NotifyVisitToRedisTest.php | 2 +- .../test/Matomo/MatomoVisitSenderTest.php | 10 ++--- .../ShortUrl/DeleteShortUrlServiceTest.php | 2 +- .../test/ShortUrl/ShortUrlResolverTest.php | 4 +- module/Core/test/Visit/Entity/VisitTest.php | 2 +- .../Visit/Geolocation/VisitLocatorTest.php | 4 +- .../Geolocation/VisitToLocationHelperTest.php | 2 +- .../NonOrphanVisitsPaginatorAdapterTest.php | 2 +- .../OrphanVisitsPaginatorAdapterTest.php | 2 +- .../Core/test/Visit/VisitsStatsHelperTest.php | 12 +++--- module/Core/test/Visit/VisitsTrackerTest.php | 27 ++++++------- .../Action/Visit/OrphanVisitsActionTest.php | 2 +- 29 files changed, 104 insertions(+), 104 deletions(-) diff --git a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php index 59c6b72f..b17ca369 100644 --- a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php @@ -107,7 +107,7 @@ class LocateVisitsCommandTest extends TestCase #[Test, DataProvider('provideIgnoredAddresses')] public function localhostAndEmptyAddressesAreIgnored(IpCannotBeLocatedException $e, string $message): void { - $visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance()); + $visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::empty()); $location = VisitLocation::fromGeolocation(Location::emptyInstance()); $this->lock->method('acquire')->with($this->isFalse())->willReturn(true); diff --git a/module/Core/src/Visit/Model/Visitor.php b/module/Core/src/Visit/Model/Visitor.php index c914f334..493280ef 100644 --- a/module/Core/src/Visit/Model/Visitor.php +++ b/module/Core/src/Visit/Model/Visitor.php @@ -51,7 +51,7 @@ final class Visitor ); } - public static function emptyInstance(): self + public static function empty(): self { return new self('', '', null, ''); } diff --git a/module/Core/src/Visit/VisitsTracker.php b/module/Core/src/Visit/VisitsTracker.php index 85085220..1d33bbd8 100644 --- a/module/Core/src/Visit/VisitsTracker.php +++ b/module/Core/src/Visit/VisitsTracker.php @@ -21,65 +21,63 @@ readonly class VisitsTracker implements VisitsTrackerInterface ) { } - public function track(ShortUrl $shortUrl, Visitor $visitor): void + public function track(ShortUrl $shortUrl, Visitor $visitor): Visit|null { - $this->trackVisit( + return $this->trackVisit( fn (Visitor $v) => Visit::forValidShortUrl($shortUrl, $v, $this->options->anonymizeRemoteAddr), $visitor, ); } - public function trackInvalidShortUrlVisit(Visitor $visitor): void + public function trackInvalidShortUrlVisit(Visitor $visitor): Visit|null { - $this->trackOrphanVisit( + return $this->trackOrphanVisit( fn (Visitor $v) => Visit::forInvalidShortUrl($v, $this->options->anonymizeRemoteAddr), $visitor, ); } - public function trackBaseUrlVisit(Visitor $visitor): void + public function trackBaseUrlVisit(Visitor $visitor): Visit|null { - $this->trackOrphanVisit( + return $this->trackOrphanVisit( fn (Visitor $v) => Visit::forBasePath($v, $this->options->anonymizeRemoteAddr), $visitor, ); } - public function trackRegularNotFoundVisit(Visitor $visitor): void + public function trackRegularNotFoundVisit(Visitor $visitor): Visit|null { - $this->trackOrphanVisit( + return $this->trackOrphanVisit( fn (Visitor $v) => Visit::forRegularNotFound($v, $this->options->anonymizeRemoteAddr), $visitor, ); } - private function trackOrphanVisit(callable $createVisit, Visitor $visitor): void + private function trackOrphanVisit(callable $createVisit, Visitor $visitor): Visit|null { if (! $this->options->trackOrphanVisits) { - return; + return null; } - $this->trackVisit($createVisit, $visitor); + return $this->trackVisit($createVisit, $visitor); } /** * @param callable(Visitor $visitor): Visit $createVisit */ - private function trackVisit(callable $createVisit, Visitor $visitor): void + private function trackVisit(callable $createVisit, Visitor $visitor): Visit|null { if ($this->options->disableTracking) { - return; + return null; } $visit = $createVisit($visitor->normalizeForTrackingOptions($this->options)); - // Wrap persisting and flushing the visit in a transaction, so that the ShortUrlVisitsCountTracker performs - // changes inside that very same transaction atomically - $this->em->wrapInTransaction(function () use ($visit): void { - $this->em->persist($visit); - $this->em->flush(); - }); - + // Wrap persisting the visit in a transaction, so that the ShortUrlVisitsCountTracker performs changes inside + // that very same transaction atomically + $this->em->wrapInTransaction(fn () => $this->em->persist($visit)); $this->eventDispatcher->dispatch(new UrlVisited($visit->getId(), $visitor->remoteAddress)); + + return $visit; } } diff --git a/module/Core/src/Visit/VisitsTrackerInterface.php b/module/Core/src/Visit/VisitsTrackerInterface.php index dc650326..da2eae84 100644 --- a/module/Core/src/Visit/VisitsTrackerInterface.php +++ b/module/Core/src/Visit/VisitsTrackerInterface.php @@ -5,15 +5,16 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Visit; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; +use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Model\Visitor; interface VisitsTrackerInterface { - public function track(ShortUrl $shortUrl, Visitor $visitor): void; + public function track(ShortUrl $shortUrl, Visitor $visitor): Visit|null; - public function trackInvalidShortUrlVisit(Visitor $visitor): void; + public function trackInvalidShortUrlVisit(Visitor $visitor): Visit|null; - public function trackBaseUrlVisit(Visitor $visitor): void; + public function trackBaseUrlVisit(Visitor $visitor): Visit|null; - public function trackRegularNotFoundVisit(Visitor $visitor): void; + public function trackRegularNotFoundVisit(Visitor $visitor): Visit|null; } diff --git a/module/Core/test-db/ShortUrl/Repository/DeleteExpiredShortUrlsRepositoryTest.php b/module/Core/test-db/ShortUrl/Repository/DeleteExpiredShortUrlsRepositoryTest.php index 1751aac9..2e10d935 100644 --- a/module/Core/test-db/ShortUrl/Repository/DeleteExpiredShortUrlsRepositoryTest.php +++ b/module/Core/test-db/ShortUrl/Repository/DeleteExpiredShortUrlsRepositoryTest.php @@ -92,7 +92,7 @@ class DeleteExpiredShortUrlsRepositoryTest extends DatabaseTestCase $this->getEntityManager()->persist($shortUrl); for ($j = 0; $j < $visitsPerShortUrl; $j++) { - $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance())); + $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl, Visitor::empty())); } } } diff --git a/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php b/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php index 26d2dff5..435c3e58 100644 --- a/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php +++ b/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php @@ -74,7 +74,7 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase $foo2 = ShortUrl::withLongUrl('https://foo_2'); $visits2 = array_map(function () use ($foo2) { - $visit = Visit::forValidShortUrl($foo2, Visitor::emptyInstance()); + $visit = Visit::forValidShortUrl($foo2, Visitor::empty()); $this->getEntityManager()->persist($visit); return $visit; @@ -304,9 +304,9 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase 'maxVisits' => 3, ]), $this->relationResolver); $this->getEntityManager()->persist($shortUrl4); - $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl4, Visitor::emptyInstance())); - $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl4, Visitor::emptyInstance())); - $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl4, Visitor::emptyInstance())); + $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl4, Visitor::empty())); + $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl4, Visitor::empty())); + $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl4, Visitor::empty())); $this->getEntityManager()->flush(); diff --git a/module/Core/test-db/Tag/Repository/TagRepositoryTest.php b/module/Core/test-db/Tag/Repository/TagRepositoryTest.php index 34210dbe..224e0c11 100644 --- a/module/Core/test-db/Tag/Repository/TagRepositoryTest.php +++ b/module/Core/test-db/Tag/Repository/TagRepositoryTest.php @@ -79,13 +79,13 @@ class TagRepositoryTest extends DatabaseTestCase $shortUrl = ShortUrl::create($metaWithTags($firstUrlTags, $apiKey), $this->relationResolver); $this->getEntityManager()->persist($shortUrl); - $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance())); - $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance())); + $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl, Visitor::empty())); + $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl, Visitor::empty())); $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl, Visitor::botInstance())); $shortUrl2 = ShortUrl::create($metaWithTags($secondUrlTags, null), $this->relationResolver); $this->getEntityManager()->persist($shortUrl2); - $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl2, Visitor::emptyInstance())); + $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl2, Visitor::empty())); // One of the tags has two extra short URLs, but with no visits $this->getEntityManager()->persist( diff --git a/module/Core/test-db/Visit/Listener/OrphanVisitsCountTrackerTest.php b/module/Core/test-db/Visit/Listener/OrphanVisitsCountTrackerTest.php index ad8edcd2..34eeb9f9 100644 --- a/module/Core/test-db/Visit/Listener/OrphanVisitsCountTrackerTest.php +++ b/module/Core/test-db/Visit/Listener/OrphanVisitsCountTrackerTest.php @@ -26,7 +26,7 @@ class OrphanVisitsCountTrackerTest extends DatabaseTestCase #[Test] public function createsNewEntriesWhenNoneExist(): void { - $visit = Visit::forBasePath(Visitor::emptyInstance()); + $visit = Visit::forBasePath(Visitor::empty()); $this->getEntityManager()->persist($visit); $this->getEntityManager()->flush(); @@ -47,7 +47,7 @@ class OrphanVisitsCountTrackerTest extends DatabaseTestCase } $this->getEntityManager()->flush(); - $visit = Visit::forRegularNotFound(Visitor::emptyInstance()); + $visit = Visit::forRegularNotFound(Visitor::empty()); $this->getEntityManager()->persist($visit); $this->getEntityManager()->flush(); diff --git a/module/Core/test-db/Visit/Listener/ShortUrlVisitsCountTrackerTest.php b/module/Core/test-db/Visit/Listener/ShortUrlVisitsCountTrackerTest.php index 7a2a6c29..7a4c4d18 100644 --- a/module/Core/test-db/Visit/Listener/ShortUrlVisitsCountTrackerTest.php +++ b/module/Core/test-db/Visit/Listener/ShortUrlVisitsCountTrackerTest.php @@ -30,7 +30,7 @@ class ShortUrlVisitsCountTrackerTest extends DatabaseTestCase $shortUrl = ShortUrl::createFake(); $this->getEntityManager()->persist($shortUrl); - $visit = Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance()); + $visit = Visit::forValidShortUrl($shortUrl, Visitor::empty()); $this->getEntityManager()->persist($visit); $this->getEntityManager()->flush(); @@ -54,7 +54,7 @@ class ShortUrlVisitsCountTrackerTest extends DatabaseTestCase } $this->getEntityManager()->flush(); - $visit = Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance()); + $visit = Visit::forValidShortUrl($shortUrl, Visitor::empty()); $this->getEntityManager()->persist($visit); $this->getEntityManager()->flush(); diff --git a/module/Core/test-db/Visit/Repository/VisitDeleterRepositoryTest.php b/module/Core/test-db/Visit/Repository/VisitDeleterRepositoryTest.php index 6529c6a9..eedd2897 100644 --- a/module/Core/test-db/Visit/Repository/VisitDeleterRepositoryTest.php +++ b/module/Core/test-db/Visit/Repository/VisitDeleterRepositoryTest.php @@ -28,8 +28,8 @@ class VisitDeleterRepositoryTest extends DatabaseTestCase { $shortUrl1 = ShortUrl::withLongUrl('https://foo.com'); $this->getEntityManager()->persist($shortUrl1); - $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl1, Visitor::emptyInstance())); - $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl1, Visitor::emptyInstance())); + $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl1, Visitor::empty())); + $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl1, Visitor::empty())); $shortUrl2 = ShortUrl::create(ShortUrlCreation::fromRawData([ ShortUrlInputFilter::LONG_URL => 'https://foo.com', @@ -37,17 +37,17 @@ class VisitDeleterRepositoryTest extends DatabaseTestCase ShortUrlInputFilter::CUSTOM_SLUG => 'foo', ]), new PersistenceShortUrlRelationResolver($this->getEntityManager())); $this->getEntityManager()->persist($shortUrl2); - $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl2, Visitor::emptyInstance())); - $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl2, Visitor::emptyInstance())); - $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl2, Visitor::emptyInstance())); - $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl2, Visitor::emptyInstance())); + $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl2, Visitor::empty())); + $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl2, Visitor::empty())); + $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl2, Visitor::empty())); + $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl2, Visitor::empty())); $shortUrl3 = ShortUrl::create(ShortUrlCreation::fromRawData([ ShortUrlInputFilter::LONG_URL => 'https://foo.com', ShortUrlInputFilter::CUSTOM_SLUG => 'foo', ]), new PersistenceShortUrlRelationResolver($this->getEntityManager())); $this->getEntityManager()->persist($shortUrl3); - $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl3, Visitor::emptyInstance())); + $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl3, Visitor::empty())); $this->getEntityManager()->flush(); @@ -62,7 +62,7 @@ class VisitDeleterRepositoryTest extends DatabaseTestCase #[Test] public function deletesExpectedOrphanVisits(): void { - $visitor = Visitor::emptyInstance(); + $visitor = Visitor::empty(); $this->getEntityManager()->persist(Visit::forBasePath($visitor)); $this->getEntityManager()->persist(Visit::forInvalidShortUrl($visitor)); $this->getEntityManager()->persist(Visit::forRegularNotFound($visitor)); diff --git a/module/Core/test-db/Visit/Repository/VisitIterationRepositoryTest.php b/module/Core/test-db/Visit/Repository/VisitIterationRepositoryTest.php index ee5843d5..60c2fbea 100644 --- a/module/Core/test-db/Visit/Repository/VisitIterationRepositoryTest.php +++ b/module/Core/test-db/Visit/Repository/VisitIterationRepositoryTest.php @@ -37,7 +37,7 @@ class VisitIterationRepositoryTest extends DatabaseTestCase $unmodifiedDate = Chronos::now(); for ($i = 0; $i < 6; $i++) { Chronos::setTestNow($unmodifiedDate->subDays($i)); // Enforce a different day for every visit - $visit = Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance()); + $visit = Visit::forValidShortUrl($shortUrl, Visitor::empty()); if ($i >= 2) { $location = VisitLocation::fromGeolocation(Location::emptyInstance()); diff --git a/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php b/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php index 393e41da..29227b8b 100644 --- a/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php +++ b/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php @@ -311,9 +311,9 @@ class VisitRepositoryTest extends DatabaseTestCase $this->getEntityManager()->persist($domainApiKey); // Visits not linked to any short URL - $this->getEntityManager()->persist(Visit::forBasePath(Visitor::emptyInstance())); - $this->getEntityManager()->persist(Visit::forInvalidShortUrl(Visitor::emptyInstance())); - $this->getEntityManager()->persist(Visit::forRegularNotFound(Visitor::emptyInstance())); + $this->getEntityManager()->persist(Visit::forBasePath(Visitor::empty())); + $this->getEntityManager()->persist(Visit::forInvalidShortUrl(Visitor::empty())); + $this->getEntityManager()->persist(Visit::forRegularNotFound(Visitor::empty())); $this->getEntityManager()->persist(Visit::forRegularNotFound(Visitor::botInstance())); $this->getEntityManager()->flush(); @@ -370,15 +370,15 @@ class VisitRepositoryTest extends DatabaseTestCase $botsCount = 3; for ($i = 0; $i < 6; $i++) { $this->getEntityManager()->persist($this->setDateOnVisit( - fn () => Visit::forBasePath($botsCount < 1 ? Visitor::emptyInstance() : Visitor::botInstance()), + fn () => Visit::forBasePath($botsCount < 1 ? Visitor::empty() : Visitor::botInstance()), Chronos::parse(sprintf('2020-01-0%s', $i + 1)), )); $this->getEntityManager()->persist($this->setDateOnVisit( - fn () => Visit::forInvalidShortUrl(Visitor::emptyInstance()), + fn () => Visit::forInvalidShortUrl(Visitor::empty()), Chronos::parse(sprintf('2020-01-0%s', $i + 1)), )); $this->getEntityManager()->persist($this->setDateOnVisit( - fn () => Visit::forRegularNotFound(Visitor::emptyInstance()), + fn () => Visit::forRegularNotFound(Visitor::empty()), Chronos::parse(sprintf('2020-01-0%s', $i + 1)), )); @@ -428,15 +428,15 @@ class VisitRepositoryTest extends DatabaseTestCase for ($i = 0; $i < 6; $i++) { $this->getEntityManager()->persist($this->setDateOnVisit( - fn () => Visit::forBasePath(Visitor::emptyInstance()), + fn () => Visit::forBasePath(Visitor::empty()), Chronos::parse(sprintf('2020-01-0%s', $i + 1)), )); $this->getEntityManager()->persist($this->setDateOnVisit( - fn () => Visit::forInvalidShortUrl(Visitor::emptyInstance()), + fn () => Visit::forInvalidShortUrl(Visitor::empty()), Chronos::parse(sprintf('2020-01-0%s', $i + 1)), )); $this->getEntityManager()->persist($this->setDateOnVisit( - fn () => Visit::forRegularNotFound(Visitor::emptyInstance()), + fn () => Visit::forRegularNotFound(Visitor::empty()), Chronos::parse(sprintf('2020-01-0%s', $i + 1)), )); } @@ -515,7 +515,7 @@ class VisitRepositoryTest extends DatabaseTestCase { $this->assertNull($this->repo->findMostRecentOrphanVisit()); - $lastVisit = Visit::forBasePath(Visitor::emptyInstance()); + $lastVisit = Visit::forBasePath(Visitor::empty()); $this->getEntityManager()->persist($lastVisit); $this->getEntityManager()->flush(); @@ -567,7 +567,7 @@ class VisitRepositoryTest extends DatabaseTestCase $visit = $this->setDateOnVisit( fn () => Visit::forValidShortUrl( $shortUrl, - $botsAmount < 1 ? Visitor::emptyInstance() : Visitor::botInstance(), + $botsAmount < 1 ? Visitor::empty() : Visitor::botInstance(), ), Chronos::parse(sprintf('2016-01-%s', str_pad((string) ($i + 1), 2, '0', STR_PAD_LEFT)))->startOfDay(), ); diff --git a/module/Core/test/EventDispatcher/LocateUnlocatedVisitsTest.php b/module/Core/test/EventDispatcher/LocateUnlocatedVisitsTest.php index 0dda17b0..ef776d42 100644 --- a/module/Core/test/EventDispatcher/LocateUnlocatedVisitsTest.php +++ b/module/Core/test/EventDispatcher/LocateUnlocatedVisitsTest.php @@ -39,7 +39,7 @@ class LocateUnlocatedVisitsTest extends TestCase #[Test] public function visitToLocationHelperIsCalledToGeolocateVisits(): void { - $visit = Visit::forBasePath(Visitor::emptyInstance()); + $visit = Visit::forBasePath(Visitor::empty()); $location = Location::emptyInstance(); $this->visitToLocation->expects($this->once())->method('resolveVisitLocation')->with($visit)->willReturn( diff --git a/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php b/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php index ed0ada96..725980a1 100644 --- a/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php +++ b/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php @@ -60,7 +60,7 @@ class SendVisitToMatomoTest extends TestCase public function visitIsSentWhenItExists(string|null $originalIpAddress): void { $visitId = '123'; - $visit = Visit::forBasePath(Visitor::emptyInstance()); + $visit = Visit::forBasePath(Visitor::empty()); $this->em->expects($this->once())->method('find')->with(Visit::class, $visitId)->willReturn($visit); $this->visitSender->expects($this->once())->method('sendVisit')->with($visit, $originalIpAddress); diff --git a/module/Core/test/EventDispatcher/Mercure/NotifyVisitToMercureTest.php b/module/Core/test/EventDispatcher/Mercure/NotifyVisitToMercureTest.php index aa21411e..1e3dfb96 100644 --- a/module/Core/test/EventDispatcher/Mercure/NotifyVisitToMercureTest.php +++ b/module/Core/test/EventDispatcher/Mercure/NotifyVisitToMercureTest.php @@ -61,7 +61,7 @@ class NotifyVisitToMercureTest extends TestCase public function notificationsAreSentWhenVisitIsFound(): void { $visitId = '123'; - $visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance()); + $visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::empty()); $update = Update::forTopicAndPayload('', []); $this->em->expects($this->once())->method('find')->with(Visit::class, $visitId)->willReturn($visit); @@ -81,7 +81,7 @@ class NotifyVisitToMercureTest extends TestCase public function debugIsLoggedWhenExceptionIsThrown(): void { $visitId = '123'; - $visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance()); + $visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::empty()); $update = Update::forTopicAndPayload('', []); $e = new RuntimeException('Error'); @@ -122,7 +122,7 @@ class NotifyVisitToMercureTest extends TestCase public static function provideOrphanVisits(): iterable { - $visitor = Visitor::emptyInstance(); + $visitor = Visitor::empty(); yield VisitType::REGULAR_404->value => [Visit::forRegularNotFound($visitor)]; yield VisitType::INVALID_SHORT_URL->value => [Visit::forInvalidShortUrl($visitor)]; diff --git a/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php b/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php index 7686f4ab..2e232038 100644 --- a/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php +++ b/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php @@ -48,7 +48,7 @@ class PublishingUpdatesGeneratorTest extends TestCase 'longUrl' => 'https://longUrl', 'title' => $title, ])); - $visit = Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance()); + $visit = Visit::forValidShortUrl($shortUrl, Visitor::empty()); /** @var Update $update */ $update = $this->generator->{$method}($visit); @@ -111,7 +111,7 @@ class PublishingUpdatesGeneratorTest extends TestCase public static function provideOrphanVisits(): iterable { - $visitor = Visitor::emptyInstance(); + $visitor = Visitor::empty(); yield VisitType::REGULAR_404->value => [Visit::forRegularNotFound($visitor)]; yield VisitType::INVALID_SHORT_URL->value => [Visit::forInvalidShortUrl($visitor)]; diff --git a/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php b/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php index 1117d5d3..26785897 100644 --- a/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php +++ b/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php @@ -90,7 +90,7 @@ class NotifyVisitToRabbitMqTest extends TestCase public static function provideVisits(): iterable { - $visitor = Visitor::emptyInstance(); + $visitor = Visitor::empty(); yield 'orphan visit' => [Visit::forBasePath($visitor), ['newOrphanVisitUpdate']]; yield 'non-orphan visit' => [ @@ -110,7 +110,7 @@ class NotifyVisitToRabbitMqTest extends TestCase { $visitId = '123'; $this->em->expects($this->once())->method('find')->with(Visit::class, $visitId)->willReturn( - Visit::forBasePath(Visitor::emptyInstance()), + Visit::forBasePath(Visitor::empty()), ); $this->updatesGenerator->expects($this->once())->method('newOrphanVisitUpdate')->with( $this->isInstanceOf(Visit::class), @@ -152,7 +152,7 @@ class NotifyVisitToRabbitMqTest extends TestCase $never = static fn () => $exactly(0); yield 'non-orphan visit' => [ - Visit::forValidShortUrl(ShortUrl::withLongUrl('https://longUrl'), Visitor::emptyInstance()), + Visit::forValidShortUrl(ShortUrl::withLongUrl('https://longUrl'), Visitor::empty()), function (MockObject & PublishingUpdatesGeneratorInterface $updatesGenerator) use ($once, $never): void { $update = Update::forTopicAndPayload('', []); $updatesGenerator->expects($never())->method('newOrphanVisitUpdate'); @@ -166,7 +166,7 @@ class NotifyVisitToRabbitMqTest extends TestCase }, ]; yield 'orphan visit' => [ - Visit::forBasePath(Visitor::emptyInstance()), + Visit::forBasePath(Visitor::empty()), function (MockObject & PublishingUpdatesGeneratorInterface $updatesGenerator) use ($once, $never): void { $update = Update::forTopicAndPayload('', []); $updatesGenerator->expects($once())->method('newOrphanVisitUpdate')->willReturn($update); diff --git a/module/Core/test/EventDispatcher/RedisPubSub/NotifyVisitToRedisTest.php b/module/Core/test/EventDispatcher/RedisPubSub/NotifyVisitToRedisTest.php index cbccffd7..20cd786a 100644 --- a/module/Core/test/EventDispatcher/RedisPubSub/NotifyVisitToRedisTest.php +++ b/module/Core/test/EventDispatcher/RedisPubSub/NotifyVisitToRedisTest.php @@ -53,7 +53,7 @@ class NotifyVisitToRedisTest extends TestCase { $visitId = '123'; $this->em->expects($this->once())->method('find')->with(Visit::class, $visitId)->willReturn( - Visit::forBasePath(Visitor::emptyInstance()), + Visit::forBasePath(Visitor::empty()), ); $this->updatesGenerator->expects($this->once())->method('newOrphanVisitUpdate')->with( $this->isInstanceOf(Visit::class), diff --git a/module/Core/test/Matomo/MatomoVisitSenderTest.php b/module/Core/test/Matomo/MatomoVisitSenderTest.php index bf568bfb..f78d0f33 100644 --- a/module/Core/test/Matomo/MatomoVisitSenderTest.php +++ b/module/Core/test/Matomo/MatomoVisitSenderTest.php @@ -77,9 +77,9 @@ class MatomoVisitSenderTest extends TestCase public static function provideTrackerMethods(): iterable { - yield 'unlocated orphan visit' => [Visit::forBasePath(Visitor::emptyInstance()), null, []]; + yield 'unlocated orphan visit' => [Visit::forBasePath(Visitor::empty()), null, []]; yield 'located regular visit' => [ - Visit::forValidShortUrl(ShortUrl::withLongUrl('https://shlink.io'), Visitor::emptyInstance()) + Visit::forValidShortUrl(ShortUrl::withLongUrl('https://shlink.io'), Visitor::empty()) ->locate(VisitLocation::fromGeolocation(new Location( countryCode: 'countryCode', countryName: 'countryName', @@ -115,7 +115,7 @@ class MatomoVisitSenderTest extends TestCase public static function provideUrlsToTrack(): iterable { - yield 'orphan visit without visited URL' => [Visit::forBasePath(Visitor::emptyInstance()), '']; + yield 'orphan visit without visited URL' => [Visit::forBasePath(Visitor::empty()), '']; yield 'orphan visit with visited URL' => [ Visit::forBasePath(new Visitor('', '', null, 'https://s.test/foo')), 'https://s.test/foo', @@ -126,7 +126,7 @@ class MatomoVisitSenderTest extends TestCase ShortUrlInputFilter::LONG_URL => 'https://shlink.io', ShortUrlInputFilter::CUSTOM_SLUG => 'bar', ]), - ), Visitor::emptyInstance()), + ), Visitor::empty()), 'http://s2.test/bar', ]; } @@ -135,7 +135,7 @@ class MatomoVisitSenderTest extends TestCase public function multipleVisitsCanBeSent(): void { $dateRange = DateRange::allTime(); - $visitor = Visitor::emptyInstance(); + $visitor = Visitor::empty(); $bot = Visitor::botInstance(); $this->visitIterationRepository->expects($this->once())->method('findAllVisits')->with($dateRange)->willReturn([ diff --git a/module/Core/test/ShortUrl/DeleteShortUrlServiceTest.php b/module/Core/test/ShortUrl/DeleteShortUrlServiceTest.php index faddafeb..73feece2 100644 --- a/module/Core/test/ShortUrl/DeleteShortUrlServiceTest.php +++ b/module/Core/test/ShortUrl/DeleteShortUrlServiceTest.php @@ -34,7 +34,7 @@ class DeleteShortUrlServiceTest extends TestCase protected function setUp(): void { $shortUrl = ShortUrl::createFake()->setVisits(new ArrayCollection( - array_map(fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance()), range(0, 10)), + array_map(fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::empty()), range(0, 10)), )); $this->shortCode = $shortUrl->getShortCode(); diff --git a/module/Core/test/ShortUrl/ShortUrlResolverTest.php b/module/Core/test/ShortUrl/ShortUrlResolverTest.php index 4d199d67..d565a352 100644 --- a/module/Core/test/ShortUrl/ShortUrlResolverTest.php +++ b/module/Core/test/ShortUrl/ShortUrlResolverTest.php @@ -128,7 +128,7 @@ class ShortUrlResolverTest extends TestCase ShortUrlCreation::fromRawData(['maxVisits' => 3, 'longUrl' => 'https://longUrl']), ); $shortUrl->setVisits(new ArrayCollection(array_map( - fn () => Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance()), + fn () => Visit::forValidShortUrl($shortUrl, Visitor::empty()), range(0, 4), ))); @@ -147,7 +147,7 @@ class ShortUrlResolverTest extends TestCase 'longUrl' => 'https://longUrl', ])); $shortUrl->setVisits(new ArrayCollection(array_map( - fn () => Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance()), + fn () => Visit::forValidShortUrl($shortUrl, Visitor::empty()), range(0, 4), ))); diff --git a/module/Core/test/Visit/Entity/VisitTest.php b/module/Core/test/Visit/Entity/VisitTest.php index 3556c1f1..edb47a53 100644 --- a/module/Core/test/Visit/Entity/VisitTest.php +++ b/module/Core/test/Visit/Entity/VisitTest.php @@ -55,7 +55,7 @@ class VisitTest extends TestCase public static function provideOrphanVisits(): iterable { yield 'base path visit' => [ - $visit = Visit::forBasePath(Visitor::emptyInstance()), + $visit = Visit::forBasePath(Visitor::empty()), [ 'referer' => '', 'date' => $visit->date->toAtomString(), diff --git a/module/Core/test/Visit/Geolocation/VisitLocatorTest.php b/module/Core/test/Visit/Geolocation/VisitLocatorTest.php index f1d86f63..cd6f12da 100644 --- a/module/Core/test/Visit/Geolocation/VisitLocatorTest.php +++ b/module/Core/test/Visit/Geolocation/VisitLocatorTest.php @@ -48,7 +48,7 @@ class VisitLocatorTest extends TestCase $unlocatedVisits = array_map( fn (int $i) => Visit::forValidShortUrl( ShortUrl::withLongUrl(sprintf('https://short_code_%s', $i)), - Visitor::emptyInstance(), + Visitor::empty(), ), range(1, 200), ); @@ -87,7 +87,7 @@ class VisitLocatorTest extends TestCase bool $isNonLocatableAddress, ): void { $unlocatedVisits = [ - Visit::forValidShortUrl(ShortUrl::withLongUrl('https://foo'), Visitor::emptyInstance()), + Visit::forValidShortUrl(ShortUrl::withLongUrl('https://foo'), Visitor::empty()), ]; $this->repo->expects($this->once())->method($expectedRepoMethodName)->willReturn($unlocatedVisits); diff --git a/module/Core/test/Visit/Geolocation/VisitToLocationHelperTest.php b/module/Core/test/Visit/Geolocation/VisitToLocationHelperTest.php index 57926afe..1f6b7f09 100644 --- a/module/Core/test/Visit/Geolocation/VisitToLocationHelperTest.php +++ b/module/Core/test/Visit/Geolocation/VisitToLocationHelperTest.php @@ -40,7 +40,7 @@ class VisitToLocationHelperTest extends TestCase public static function provideNonLocatableVisits(): iterable { - yield [Visit::forBasePath(Visitor::emptyInstance()), IpCannotBeLocatedException::forEmptyAddress()]; + yield [Visit::forBasePath(Visitor::empty()), IpCannotBeLocatedException::forEmptyAddress()]; yield [ Visit::forBasePath(new Visitor('foo', 'bar', IpAddress::LOCALHOST, '')), IpCannotBeLocatedException::forLocalhost(), diff --git a/module/Core/test/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapterTest.php b/module/Core/test/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapterTest.php index dd998f71..2dbaa25a 100644 --- a/module/Core/test/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapterTest.php +++ b/module/Core/test/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapterTest.php @@ -53,7 +53,7 @@ class NonOrphanVisitsPaginatorAdapterTest extends TestCase #[Test, DataProvider('provideLimitAndOffset')] public function getSliceDelegatesToRepository(int $limit, int $offset): void { - $visitor = Visitor::emptyInstance(); + $visitor = Visitor::empty(); $list = [Visit::forRegularNotFound($visitor), Visit::forInvalidShortUrl($visitor)]; $this->repo->expects($this->once())->method('findNonOrphanVisits')->with(new VisitsListFiltering( $this->params->dateRange, diff --git a/module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php b/module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php index 3e50faf0..abad2fc0 100644 --- a/module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php +++ b/module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php @@ -53,7 +53,7 @@ class OrphanVisitsPaginatorAdapterTest extends TestCase #[Test, DataProvider('provideLimitAndOffset')] public function getSliceDelegatesToRepository(int $limit, int $offset): void { - $visitor = Visitor::emptyInstance(); + $visitor = Visitor::empty(); $list = [Visit::forRegularNotFound($visitor), Visit::forInvalidShortUrl($visitor)]; $this->repo->expects($this->once())->method('findOrphanVisits')->with(new OrphanVisitsListFiltering( dateRange: $this->params->dateRange, diff --git a/module/Core/test/Visit/VisitsStatsHelperTest.php b/module/Core/test/Visit/VisitsStatsHelperTest.php index 10c11b64..d6762c00 100644 --- a/module/Core/test/Visit/VisitsStatsHelperTest.php +++ b/module/Core/test/Visit/VisitsStatsHelperTest.php @@ -104,7 +104,7 @@ class VisitsStatsHelperTest extends TestCase $repo->expects($this->once())->method('shortCodeIsInUse')->with($identifier, $spec)->willReturn(true); $list = array_map( - static fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance()), + static fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::empty()), range(0, 1), ); $repo2 = $this->createMock(VisitRepository::class); @@ -164,7 +164,7 @@ class VisitsStatsHelperTest extends TestCase $repo->expects($this->once())->method('tagExists')->with($tag, $apiKey)->willReturn(true); $list = array_map( - static fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance()), + static fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::empty()), range(0, 1), ); $repo2 = $this->createMock(VisitRepository::class); @@ -205,7 +205,7 @@ class VisitsStatsHelperTest extends TestCase $repo->expects($this->once())->method('domainExists')->with($domain, $apiKey)->willReturn(true); $list = array_map( - static fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance()), + static fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::empty()), range(0, 1), ); $repo2 = $this->createMock(VisitRepository::class); @@ -235,7 +235,7 @@ class VisitsStatsHelperTest extends TestCase $repo->expects($this->never())->method('domainExists'); $list = array_map( - static fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance()), + static fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::empty()), range(0, 1), ); $repo2 = $this->createMock(VisitRepository::class); @@ -261,7 +261,7 @@ class VisitsStatsHelperTest extends TestCase #[Test] public function orphanVisitsAreReturnedAsExpected(): void { - $list = array_map(static fn () => Visit::forBasePath(Visitor::emptyInstance()), range(0, 3)); + $list = array_map(static fn () => Visit::forBasePath(Visitor::empty()), range(0, 3)); $repo = $this->createMock(VisitRepository::class); $repo->expects($this->once())->method('countOrphanVisits')->with( $this->isInstanceOf(OrphanVisitsCountFiltering::class), @@ -280,7 +280,7 @@ class VisitsStatsHelperTest extends TestCase public function nonOrphanVisitsAreReturnedAsExpected(): void { $list = array_map( - static fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance()), + static fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::empty()), range(0, 3), ); $repo = $this->createMock(VisitRepository::class); diff --git a/module/Core/test/Visit/VisitsTrackerTest.php b/module/Core/test/Visit/VisitsTrackerTest.php index f45a27d8..bfcf2828 100644 --- a/module/Core/test/Visit/VisitsTrackerTest.php +++ b/module/Core/test/Visit/VisitsTrackerTest.php @@ -33,43 +33,44 @@ class VisitsTrackerTest extends TestCase #[Test, DataProvider('provideTrackingMethodNames')] public function trackPersistsVisitAndDispatchesEvent(string $method, array $args): void { - $this->em->expects($this->once())->method('persist')->with( - $this->callback(fn (Visit $visit) => $visit->setId('1') !== null), - ); - $this->em->expects($this->once())->method('flush'); + $this->em->expects($this->once())->method('persist')->with($this->isInstanceOf(Visit::class)); $this->eventDispatcher->expects($this->once())->method('dispatch')->with( $this->isInstanceOf(UrlVisited::class), ); - $this->visitsTracker()->{$method}(...$args); + $result = $this->visitsTracker()->{$method}(...$args); + + self::assertInstanceOf(Visit::class, $result); } #[Test, DataProvider('provideTrackingMethodNames')] public function trackingIsSkippedCompletelyWhenDisabledFromOptions(string $method, array $args): void { $this->em->expects($this->never())->method('persist'); - $this->em->expects($this->never())->method('flush'); $this->eventDispatcher->expects($this->never())->method('dispatch'); - $this->visitsTracker(new TrackingOptions(disableTracking: true))->{$method}(...$args); + $result = $this->visitsTracker(new TrackingOptions(disableTracking: true))->{$method}(...$args); + + self::assertNull($result); } public static function provideTrackingMethodNames(): iterable { - yield 'track' => ['track', [ShortUrl::createFake(), Visitor::emptyInstance()]]; - yield 'trackInvalidShortUrlVisit' => ['trackInvalidShortUrlVisit', [Visitor::emptyInstance()]]; - yield 'trackBaseUrlVisit' => ['trackBaseUrlVisit', [Visitor::emptyInstance()]]; - yield 'trackRegularNotFoundVisit' => ['trackRegularNotFoundVisit', [Visitor::emptyInstance()]]; + yield 'track' => ['track', [ShortUrl::createFake(), Visitor::empty()]]; + yield 'trackInvalidShortUrlVisit' => ['trackInvalidShortUrlVisit', [Visitor::empty()]]; + yield 'trackBaseUrlVisit' => ['trackBaseUrlVisit', [Visitor::empty()]]; + yield 'trackRegularNotFoundVisit' => ['trackRegularNotFoundVisit', [Visitor::empty()]]; } #[Test, DataProvider('provideOrphanTrackingMethodNames')] public function orphanVisitsAreNotTrackedWhenDisabled(string $method): void { $this->em->expects($this->never())->method('persist'); - $this->em->expects($this->never())->method('flush'); $this->eventDispatcher->expects($this->never())->method('dispatch'); - $this->visitsTracker(new TrackingOptions(trackOrphanVisits: false))->{$method}(Visitor::emptyInstance()); + $result = $this->visitsTracker(new TrackingOptions(trackOrphanVisits: false))->{$method}(Visitor::empty()); + + self::assertNull($result); } public static function provideOrphanTrackingMethodNames(): iterable diff --git a/module/Rest/test/Action/Visit/OrphanVisitsActionTest.php b/module/Rest/test/Action/Visit/OrphanVisitsActionTest.php index d5bdfef9..6892d3bd 100644 --- a/module/Rest/test/Action/Visit/OrphanVisitsActionTest.php +++ b/module/Rest/test/Action/Visit/OrphanVisitsActionTest.php @@ -35,7 +35,7 @@ class OrphanVisitsActionTest extends TestCase #[Test] public function requestIsHandled(): void { - $visitor = Visitor::emptyInstance(); + $visitor = Visitor::empty(); $visits = [Visit::forInvalidShortUrl($visitor), Visit::forRegularNotFound($visitor)]; $this->visitsHelper->expects($this->once())->method('orphanVisits')->with( $this->isInstanceOf(OrphanVisitsParams::class), From 48ecef3436788c84d5a7db25d8cafe5a3794c3e3 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 11 Nov 2024 08:58:16 +0100 Subject: [PATCH 43/80] Update RequestTracker so that its methods return the new Visit instance, if any --- module/Core/src/Visit/RequestTracker.php | 23 +++++++++++-------- .../src/Visit/RequestTrackerInterface.php | 5 ++-- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/module/Core/src/Visit/RequestTracker.php b/module/Core/src/Visit/RequestTracker.php index 02fbd94e..cb36eee2 100644 --- a/module/Core/src/Visit/RequestTracker.php +++ b/module/Core/src/Visit/RequestTracker.php @@ -12,6 +12,7 @@ use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType; use Shlinkio\Shlink\Core\Exception\InvalidIpFormatException; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\Util\IpAddressUtils; +use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Model\Visitor; use function Shlinkio\Shlink\Core\ipAddressFromRequest; @@ -22,24 +23,26 @@ readonly class RequestTracker implements RequestTrackerInterface, RequestMethodI { } - public function trackIfApplicable(ShortUrl $shortUrl, ServerRequestInterface $request): void - { - if ($this->shouldTrackRequest($request)) { - $this->visitsTracker->track($shortUrl, Visitor::fromRequest($request)); - } - } - - public function trackNotFoundIfApplicable(ServerRequestInterface $request): void + public function trackIfApplicable(ShortUrl $shortUrl, ServerRequestInterface $request): Visit|null { if (! $this->shouldTrackRequest($request)) { - return; + return null; + } + + return $this->visitsTracker->track($shortUrl, Visitor::fromRequest($request)); + } + + public function trackNotFoundIfApplicable(ServerRequestInterface $request): Visit|null + { + if (! $this->shouldTrackRequest($request)) { + return null; } /** @var NotFoundType|null $notFoundType */ $notFoundType = $request->getAttribute(NotFoundType::class); $visitor = Visitor::fromRequest($request); - match (true) { + return match (true) { $notFoundType?->isBaseUrl() => $this->visitsTracker->trackBaseUrlVisit($visitor), $notFoundType?->isRegularNotFound() => $this->visitsTracker->trackRegularNotFoundVisit($visitor), $notFoundType?->isInvalidShortUrl() => $this->visitsTracker->trackInvalidShortUrlVisit($visitor), diff --git a/module/Core/src/Visit/RequestTrackerInterface.php b/module/Core/src/Visit/RequestTrackerInterface.php index 9048b07f..4fb159b0 100644 --- a/module/Core/src/Visit/RequestTrackerInterface.php +++ b/module/Core/src/Visit/RequestTrackerInterface.php @@ -6,10 +6,11 @@ namespace Shlinkio\Shlink\Core\Visit; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; +use Shlinkio\Shlink\Core\Visit\Entity\Visit; interface RequestTrackerInterface { - public function trackIfApplicable(ShortUrl $shortUrl, ServerRequestInterface $request): void; + public function trackIfApplicable(ShortUrl $shortUrl, ServerRequestInterface $request): Visit|null; - public function trackNotFoundIfApplicable(ServerRequestInterface $request): void; + public function trackNotFoundIfApplicable(ServerRequestInterface $request): Visit|null; } From 7ca605e2169dbc874d4e795fb41d5cd44d93aef2 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 11 Nov 2024 09:31:23 +0100 Subject: [PATCH 44/80] Remove unnecessary flush calls when used in wrapInTransaction --- module/Rest/src/ApiKey/Repository/ApiKeyRepository.php | 1 - module/Rest/src/Service/ApiKeyService.php | 8 +++----- module/Rest/test/Service/ApiKeyServiceTest.php | 7 ------- 3 files changed, 3 insertions(+), 13 deletions(-) diff --git a/module/Rest/src/ApiKey/Repository/ApiKeyRepository.php b/module/Rest/src/ApiKey/Repository/ApiKeyRepository.php index e1fbf3a6..6b282a07 100644 --- a/module/Rest/src/ApiKey/Repository/ApiKeyRepository.php +++ b/module/Rest/src/ApiKey/Repository/ApiKeyRepository.php @@ -36,7 +36,6 @@ class ApiKeyRepository extends EntitySpecificationRepository implements ApiKeyRe $initialApiKey = ApiKey::fromMeta(ApiKeyMeta::fromParams(key: $apiKey)); $em->persist($initialApiKey); - $em->flush(); return $initialApiKey; }); diff --git a/module/Rest/src/Service/ApiKeyService.php b/module/Rest/src/Service/ApiKeyService.php index 09d1bb76..f60c2179 100644 --- a/module/Rest/src/Service/ApiKeyService.php +++ b/module/Rest/src/Service/ApiKeyService.php @@ -27,7 +27,6 @@ readonly class ApiKeyService implements ApiKeyServiceInterface return $this->em->wrapInTransaction(function () use ($apiKeyMeta) { $apiKey = ApiKey::fromMeta($this->ensureUniqueName($apiKeyMeta)); $this->em->persist($apiKey); - $this->em->flush(); return $apiKey; }); @@ -120,7 +119,7 @@ readonly class ApiKeyService implements ApiKeyServiceInterface return $apiKey; } - return $this->em->wrapInTransaction(function () use ($apiKeyRenaming, $apiKey) { + $this->em->wrapInTransaction(function () use ($apiKeyRenaming, $apiKey): void { if ($this->repo->nameExists($apiKeyRenaming->newName)) { throw new InvalidArgumentException( sprintf('Another API key with name "%s" already exists', $apiKeyRenaming->newName), @@ -128,10 +127,9 @@ readonly class ApiKeyService implements ApiKeyServiceInterface } $apiKey->name = $apiKeyRenaming->newName; - $this->em->flush(); - - return $apiKey; }); + + return $apiKey; } private function findByKey(string $key): ApiKey|null diff --git a/module/Rest/test/Service/ApiKeyServiceTest.php b/module/Rest/test/Service/ApiKeyServiceTest.php index ee33e109..bf80ae60 100644 --- a/module/Rest/test/Service/ApiKeyServiceTest.php +++ b/module/Rest/test/Service/ApiKeyServiceTest.php @@ -45,7 +45,6 @@ class ApiKeyServiceTest extends TestCase $this->repo->expects($this->once())->method('nameExists')->with( ! empty($name) ? $name : $this->isType('string'), )->willReturn(false); - $this->em->expects($this->once())->method('flush'); $this->em->expects($this->once())->method('persist')->with($this->isInstanceOf(ApiKey::class)); $meta = ApiKeyMeta::fromParams(name: $name, expirationDate: $date, roleDefinitions: $roles); @@ -87,7 +86,6 @@ class ApiKeyServiceTest extends TestCase $callCount++; return $callCount < 3; }); - $this->em->expects($this->once())->method('flush'); $this->em->expects($this->once())->method('persist')->with($this->isInstanceOf(ApiKey::class)); $this->service->create(ApiKeyMeta::create()); @@ -97,7 +95,6 @@ class ApiKeyServiceTest extends TestCase public function exceptionIsThrownWhileCreatingIfExplicitlyProvidedNameIsInUse(): void { $this->repo->expects($this->once())->method('nameExists')->with('the_name')->willReturn(true); - $this->em->expects($this->never())->method('flush'); $this->em->expects($this->never())->method('persist'); $this->expectException(InvalidArgumentException::class); @@ -219,7 +216,6 @@ class ApiKeyServiceTest extends TestCase $this->repo->expects($this->once())->method('findOneBy')->with(['name' => 'old'])->willReturn(null); $this->repo->expects($this->never())->method('nameExists'); - $this->em->expects($this->never())->method('flush'); $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('API key with name "old" could not be found'); @@ -235,7 +231,6 @@ class ApiKeyServiceTest extends TestCase $this->repo->expects($this->once())->method('findOneBy')->with(['name' => 'same_value'])->willReturn($apiKey); $this->repo->expects($this->never())->method('nameExists'); - $this->em->expects($this->never())->method('flush'); $result = $this->service->renameApiKey($renaming); @@ -250,7 +245,6 @@ class ApiKeyServiceTest extends TestCase $this->repo->expects($this->once())->method('findOneBy')->with(['name' => 'old'])->willReturn($apiKey); $this->repo->expects($this->once())->method('nameExists')->with('new')->willReturn(true); - $this->em->expects($this->never())->method('flush'); $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Another API key with name "new" already exists'); @@ -266,7 +260,6 @@ class ApiKeyServiceTest extends TestCase $this->repo->expects($this->once())->method('findOneBy')->with(['name' => 'old'])->willReturn($apiKey); $this->repo->expects($this->once())->method('nameExists')->with('new')->willReturn(false); - $this->em->expects($this->once())->method('flush'); $result = $this->service->renameApiKey($renaming); From 9a69d0653114367b0984d9e2fdb70e9813818656 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 12 Nov 2024 10:22:23 +0100 Subject: [PATCH 45/80] Update to PHPStan 2.0 --- CHANGELOG.md | 1 + composer.json | 8 ++++---- .../Command/ShortUrl/CreateShortUrlCommand.php | 2 +- .../Command/ShortUrl/ListShortUrlsCommand.php | 2 +- .../Visit/AbstractVisitsListCommand.php | 4 ++-- .../test/GeoLite/GeolocationDbUpdaterTest.php | 18 +++++++++++------- module/Core/functions/functions.php | 2 +- .../src/Importer/ImportedLinksProcessor.php | 8 ++++---- .../PersistenceShortUrlRelationResolver.php | 10 +++++----- .../Core/src/Tag/Repository/TagRepository.php | 6 +++--- module/Core/src/Tag/TagService.php | 8 +++++--- .../src/Visit/Repository/VisitRepository.php | 4 ++-- module/Core/src/Visit/VisitsStatsHelper.php | 16 ++++++++-------- .../Domain/Repository/DomainRepositoryTest.php | 2 +- .../ShortUrlRedirectRuleServiceTest.php | 2 -- .../ShortUrlTitleResolutionHelperTest.php | 4 ++-- ...nseImplicitOptionsMiddlewareFactoryTest.php | 8 -------- phpstan.neon | 2 +- 18 files changed, 52 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd80fa59..032be60e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * Update to Shlink PHP coding standard 2.4 * Update to `hidehalo/nanoid-php` 2.0 +* Update to PHPStan 2.0 ### Deprecated * *Nothing* diff --git a/composer.json b/composer.json index 84f626b7..88e94946 100644 --- a/composer.json +++ b/composer.json @@ -64,10 +64,10 @@ "require-dev": { "devizzent/cebe-php-openapi": "^1.0.1", "devster/ubench": "^2.1", - "phpstan/phpstan": "^1.12", - "phpstan/phpstan-doctrine": "^1.5", - "phpstan/phpstan-phpunit": "^1.4", - "phpstan/phpstan-symfony": "^1.4", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-doctrine": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-symfony": "^2.0", "phpunit/php-code-coverage": "^11.0", "phpunit/phpcov": "^10.0", "phpunit/phpunit": "^11.4", diff --git a/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php index b6fa5034..e3a9b180 100644 --- a/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php @@ -22,7 +22,7 @@ class CreateShortUrlCommand extends Command { public const NAME = 'short-url:create'; - private SymfonyStyle|null $io; + private SymfonyStyle $io; private readonly ShortUrlDataInput $shortUrlDataInput; public function __construct( diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index fadc78e2..fffeb1f6 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -235,7 +235,7 @@ class ListShortUrlsCommand extends Command } if ($input->getOption('show-domain')) { $columnsMap['Domain'] = static fn (array $_, ShortUrl $shortUrl): string => - $shortUrl->getDomain()?->authority ?? Domain::DEFAULT_AUTHORITY; + $shortUrl->getDomain()->authority ?? Domain::DEFAULT_AUTHORITY; } if ($input->getOption('show-api-key') || $input->getOption('show-api-key-name')) { $columnsMap['API Key Name'] = static fn (array $_, ShortUrl $shortUrl): string|null => diff --git a/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php b/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php index dea28e92..b95c6845 100644 --- a/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php +++ b/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php @@ -61,8 +61,8 @@ abstract class AbstractVisitsListCommand extends Command 'date' => $visit->date->toAtomString(), 'userAgent' => $visit->userAgent, 'potentialBot' => $visit->potentialBot, - 'country' => $visit->getVisitLocation()?->countryName ?? 'Unknown', - 'city' => $visit->getVisitLocation()?->cityName ?? 'Unknown', + 'country' => $visit->getVisitLocation()->countryName ?? 'Unknown', + 'city' => $visit->getVisitLocation()->cityName ?? 'Unknown', ...$extraFields, ]; diff --git a/module/CLI/test/GeoLite/GeolocationDbUpdaterTest.php b/module/CLI/test/GeoLite/GeolocationDbUpdaterTest.php index c1cd48f5..038d570c 100644 --- a/module/CLI/test/GeoLite/GeolocationDbUpdaterTest.php +++ b/module/CLI/test/GeoLite/GeolocationDbUpdaterTest.php @@ -41,22 +41,24 @@ class GeolocationDbUpdaterTest extends TestCase #[Test] public function properResultIsReturnedWhenLicenseIsMissing(): void { - $mustBeUpdated = fn () => self::assertTrue(true); - $this->dbUpdater->expects($this->once())->method('databaseFileExists')->willReturn(false); $this->dbUpdater->expects($this->once())->method('downloadFreshCopy')->willThrowException( new MissingLicenseException(''), ); $this->geoLiteDbReader->expects($this->never())->method('metadata'); - $result = $this->geolocationDbUpdater()->checkDbUpdate($mustBeUpdated); + $isCalled = false; + $result = $this->geolocationDbUpdater()->checkDbUpdate(function () use (&$isCalled): void { + $isCalled = true; + }); + + self::assertTrue($isCalled); self::assertEquals(GeolocationResult::LICENSE_MISSING, $result); } #[Test] public function exceptionIsThrownWhenOlderDbDoesNotExistAndDownloadFails(): void { - $mustBeUpdated = fn () => self::assertTrue(true); $prev = new DbUpdateException(''); $this->dbUpdater->expects($this->once())->method('databaseFileExists')->willReturn(false); @@ -65,14 +67,17 @@ class GeolocationDbUpdaterTest extends TestCase )->willThrowException($prev); $this->geoLiteDbReader->expects($this->never())->method('metadata'); + $isCalled = false; try { - $this->geolocationDbUpdater()->checkDbUpdate($mustBeUpdated); + $this->geolocationDbUpdater()->checkDbUpdate(function () use (&$isCalled): void { + $isCalled = true; + }); self::fail(); } catch (Throwable $e) { - /** @var GeolocationDbUpdateFailedException $e */ self::assertInstanceOf(GeolocationDbUpdateFailedException::class, $e); self::assertSame($prev, $e->getPrevious()); self::assertFalse($e->olderDbExists()); + self::assertTrue($isCalled); } } @@ -92,7 +97,6 @@ class GeolocationDbUpdaterTest extends TestCase $this->geolocationDbUpdater()->checkDbUpdate(); self::fail(); } catch (Throwable $e) { - /** @var GeolocationDbUpdateFailedException $e */ self::assertInstanceOf(GeolocationDbUpdateFailedException::class, $e); self::assertSame($prev, $e->getPrevious()); self::assertTrue($e->olderDbExists()); diff --git a/module/Core/functions/functions.php b/module/Core/functions/functions.php index 00b220e9..cb8c0a8c 100644 --- a/module/Core/functions/functions.php +++ b/module/Core/functions/functions.php @@ -109,7 +109,7 @@ function normalizeLocale(string $locale): string * minimum quality * * @param non-empty-string $acceptLanguage - * @return iterable; + * @return iterable */ function acceptLanguageToLocales(string $acceptLanguage, float $minQuality = 0): iterable { diff --git a/module/Core/src/Importer/ImportedLinksProcessor.php b/module/Core/src/Importer/ImportedLinksProcessor.php index 266e9a7a..e8434d4f 100644 --- a/module/Core/src/Importer/ImportedLinksProcessor.php +++ b/module/Core/src/Importer/ImportedLinksProcessor.php @@ -8,11 +8,11 @@ use Doctrine\ORM\EntityManagerInterface; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortCodeUniquenessHelperInterface; -use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepositoryInterface; +use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface; use Shlinkio\Shlink\Core\Util\DoctrineBatchHelperInterface; use Shlinkio\Shlink\Core\Visit\Entity\Visit; -use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface; +use Shlinkio\Shlink\Core\Visit\Repository\VisitRepository; use Shlinkio\Shlink\Importer\ImportedLinksProcessorInterface; use Shlinkio\Shlink\Importer\Model\ImportedShlinkOrphanVisit; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; @@ -93,7 +93,7 @@ readonly class ImportedLinksProcessor implements ImportedLinksProcessorInterface bool $importShortCodes, callable $skipOnShortCodeConflict, ): ShortUrlImporting { - /** @var ShortUrlRepositoryInterface $shortUrlRepo */ + /** @var ShortUrlRepository $shortUrlRepo */ $shortUrlRepo = $this->em->getRepository(ShortUrl::class); $alreadyImportedShortUrl = $shortUrlRepo->findOneByImportedUrl($importedUrl); if ($alreadyImportedShortUrl !== null) { @@ -132,7 +132,7 @@ readonly class ImportedLinksProcessor implements ImportedLinksProcessorInterface { $iterable = $this->batchHelper->wrapIterable($orphanVisits, 100); - /** @var VisitRepositoryInterface $visitRepo */ + /** @var VisitRepository $visitRepo */ $visitRepo = $this->em->getRepository(Visit::class); $mostRecentOrphanVisit = $visitRepo->findMostRecentOrphanVisit(); diff --git a/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php b/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php index 2e5f3e15..df578387 100644 --- a/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php +++ b/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php @@ -11,8 +11,8 @@ use Doctrine\ORM\Events; use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\Tag\Entity\Tag; -use Symfony\Component\Lock\Lock; use Symfony\Component\Lock\LockFactory; +use Symfony\Component\Lock\SharedLockInterface; use Symfony\Component\Lock\Store\InMemoryStore; use function array_map; @@ -24,9 +24,9 @@ class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInt private array $memoizedNewDomains = []; /** @var array */ private array $memoizedNewTags = []; - /** @var array */ + /** @var array */ private array $tagLocks = []; - /** @var array */ + /** @var array */ private array $domainLocks = []; public function __construct( @@ -100,7 +100,7 @@ class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInt } /** - * @param array $locks + * @param array $locks */ private function lock(array &$locks, string $name): void { @@ -112,7 +112,7 @@ class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInt /** /** - * @param array $locks + * @param array $locks */ private function releaseLock(array &$locks, string $name): void { diff --git a/module/Core/src/Tag/Repository/TagRepository.php b/module/Core/src/Tag/Repository/TagRepository.php index 4545e46c..5f3eed11 100644 --- a/module/Core/src/Tag/Repository/TagRepository.php +++ b/module/Core/src/Tag/Repository/TagRepository.php @@ -45,7 +45,7 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito public function findTagsWithInfo(TagsListFiltering|null $filtering = null): array { $orderField = OrderableField::toValidField($filtering?->orderBy?->field); - $orderDir = $filtering?->orderBy?->direction ?? 'ASC'; + $orderDir = $filtering->orderBy->direction ?? 'ASC'; $apiKey = $filtering?->apiKey; $conn = $this->getEntityManager()->getConnection(); @@ -113,8 +113,8 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito ->from('(' . $tagsSubQb->getSQL() . ')', 't') ->leftJoin('t', '(' . $allVisitsSubQb->getSQL() . ')', 'v', $mainQb->expr()->eq('t.tag_id', 'v.tag_id')) ->leftJoin('t', '(' . $nonBotVisitsSubQb->getSQL() . ')', 'b', $mainQb->expr()->eq('t.tag_id', 'b.tag_id')) - ->setMaxResults($filtering?->limit ?? PHP_INT_MAX) - ->setFirstResult($filtering?->offset ?? 0); + ->setMaxResults($filtering->limit ?? PHP_INT_MAX) + ->setFirstResult($filtering->offset ?? 0); $mainQb->orderBy(camelCaseToSnakeCase($orderField->value), $orderDir); if ($orderField !== OrderableField::TAG) { diff --git a/module/Core/src/Tag/TagService.php b/module/Core/src/Tag/TagService.php index 3681d454..a2cbcf2c 100644 --- a/module/Core/src/Tag/TagService.php +++ b/module/Core/src/Tag/TagService.php @@ -47,9 +47,11 @@ readonly class TagService implements TagServiceInterface */ private function createPaginator(AdapterInterface $adapter, TagsParams $params): Paginator { - return (new Paginator($adapter)) - ->setMaxPerPage($params->itemsPerPage) - ->setCurrentPage($params->page); + $paginator = new Paginator($adapter); + $paginator->setMaxPerPage($params->itemsPerPage) + ->setCurrentPage($params->page); + + return $paginator; } /** diff --git a/module/Core/src/Visit/Repository/VisitRepository.php b/module/Core/src/Visit/Repository/VisitRepository.php index 1c85fe66..9c4668c1 100644 --- a/module/Core/src/Visit/Repository/VisitRepository.php +++ b/module/Core/src/Visit/Repository/VisitRepository.php @@ -11,7 +11,7 @@ use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; -use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepositoryInterface; +use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Entity\VisitLocation; use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsCountFiltering; @@ -48,7 +48,7 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo ShortUrlIdentifier $identifier, VisitsCountFiltering $filtering, ): QueryBuilder { - /** @var ShortUrlRepositoryInterface $shortUrlRepo */ + /** @var ShortUrlRepository $shortUrlRepo */ $shortUrlRepo = $this->getEntityManager()->getRepository(ShortUrl::class); $shortUrlId = $shortUrlRepo->findOne($identifier, $filtering->apiKey?->spec())?->getId() ?? '-1'; diff --git a/module/Core/src/Visit/VisitsStatsHelper.php b/module/Core/src/Visit/VisitsStatsHelper.php index f1533ddf..412decc7 100644 --- a/module/Core/src/Visit/VisitsStatsHelper.php +++ b/module/Core/src/Visit/VisitsStatsHelper.php @@ -14,7 +14,7 @@ use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; -use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepositoryInterface; +use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\Tag\Entity\Tag; use Shlinkio\Shlink\Core\Tag\Repository\TagRepository; use Shlinkio\Shlink\Core\Visit\Entity\OrphanVisitsCount; @@ -32,7 +32,7 @@ use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsCountFiltering; use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering; use Shlinkio\Shlink\Core\Visit\Repository\OrphanVisitsCountRepository; use Shlinkio\Shlink\Core\Visit\Repository\ShortUrlVisitsCountRepository; -use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface; +use Shlinkio\Shlink\Core\Visit\Repository\VisitRepository; use Shlinkio\Shlink\Rest\Entity\ApiKey; readonly class VisitsStatsHelper implements VisitsStatsHelperInterface @@ -70,13 +70,13 @@ readonly class VisitsStatsHelper implements VisitsStatsHelperInterface VisitsParams $params, ApiKey|null $apiKey = null, ): Paginator { - /** @var ShortUrlRepositoryInterface $repo */ + /** @var ShortUrlRepository $repo */ $repo = $this->em->getRepository(ShortUrl::class); if (! $repo->shortCodeIsInUse($identifier, $apiKey?->spec())) { throw ShortUrlNotFoundException::fromNotFound($identifier); } - /** @var VisitRepositoryInterface $repo */ + /** @var VisitRepository $repo */ $repo = $this->em->getRepository(Visit::class); return $this->createPaginator( @@ -96,7 +96,7 @@ readonly class VisitsStatsHelper implements VisitsStatsHelperInterface throw TagNotFoundException::fromTag($tag); } - /** @var VisitRepositoryInterface $repo */ + /** @var VisitRepository $repo */ $repo = $this->em->getRepository(Visit::class); return $this->createPaginator(new TagVisitsPaginatorAdapter($repo, $tag, $params, $apiKey), $params); @@ -113,7 +113,7 @@ readonly class VisitsStatsHelper implements VisitsStatsHelperInterface throw DomainNotFoundException::fromAuthority($domain); } - /** @var VisitRepositoryInterface $repo */ + /** @var VisitRepository $repo */ $repo = $this->em->getRepository(Visit::class); return $this->createPaginator(new DomainVisitsPaginatorAdapter($repo, $domain, $params, $apiKey), $params); @@ -124,7 +124,7 @@ readonly class VisitsStatsHelper implements VisitsStatsHelperInterface */ public function orphanVisits(OrphanVisitsParams $params, ApiKey|null $apiKey = null): Paginator { - /** @var VisitRepositoryInterface $repo */ + /** @var VisitRepository $repo */ $repo = $this->em->getRepository(Visit::class); return $this->createPaginator(new OrphanVisitsPaginatorAdapter($repo, $params, $apiKey), $params); @@ -132,7 +132,7 @@ readonly class VisitsStatsHelper implements VisitsStatsHelperInterface public function nonOrphanVisits(VisitsParams $params, ApiKey|null $apiKey = null): Paginator { - /** @var VisitRepositoryInterface $repo */ + /** @var VisitRepository $repo */ $repo = $this->em->getRepository(Visit::class); return $this->createPaginator(new NonOrphanVisitsPaginatorAdapter($repo, $params, $apiKey), $params); diff --git a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php index 0bae6bd8..ebb53c10 100644 --- a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php +++ b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php @@ -139,7 +139,7 @@ class DomainRepositoryTest extends DatabaseTestCase { } - public function resolveDomain(string|null $domain): Domain|null + public function resolveDomain(string|null $domain): Domain { return $this->domain; } diff --git a/module/Core/test/RedirectRule/ShortUrlRedirectRuleServiceTest.php b/module/Core/test/RedirectRule/ShortUrlRedirectRuleServiceTest.php index 103c6fd0..47aa6490 100644 --- a/module/Core/test/RedirectRule/ShortUrlRedirectRuleServiceTest.php +++ b/module/Core/test/RedirectRule/ShortUrlRedirectRuleServiceTest.php @@ -99,8 +99,6 @@ class ShortUrlRedirectRuleServiceTest extends TestCase $result = $this->ruleService->setRulesForShortUrl($shortUrl, $data); self::assertCount(2, $result); - self::assertInstanceOf(ShortUrlRedirectRule::class, $result[0]); - self::assertInstanceOf(ShortUrlRedirectRule::class, $result[1]); } #[Test] diff --git a/module/Core/test/ShortUrl/Helper/ShortUrlTitleResolutionHelperTest.php b/module/Core/test/ShortUrl/Helper/ShortUrlTitleResolutionHelperTest.php index b5b8e00c..d73a1a6d 100644 --- a/module/Core/test/ShortUrl/Helper/ShortUrlTitleResolutionHelperTest.php +++ b/module/Core/test/ShortUrl/Helper/ShortUrlTitleResolutionHelperTest.php @@ -90,8 +90,8 @@ class ShortUrlTitleResolutionHelperTest extends TestCase } #[Test] - #[TestWith(['TEXT/html; charset=utf-8'], name: 'charset')] - #[TestWith(['TEXT/html'], name: 'no charset')] + #[TestWith(['TEXT/html; charset=utf-8'], 'charset')] + #[TestWith(['TEXT/html'], 'no charset')] public function titleIsUpdatedWhenItCanBeResolvedFromResponse(string $contentType): void { $data = ShortUrlCreation::fromRawData(['longUrl' => self::LONG_URL]); diff --git a/module/Rest/test/Middleware/EmptyResponseImplicitOptionsMiddlewareFactoryTest.php b/module/Rest/test/Middleware/EmptyResponseImplicitOptionsMiddlewareFactoryTest.php index 74c06cd5..6c051fda 100644 --- a/module/Rest/test/Middleware/EmptyResponseImplicitOptionsMiddlewareFactoryTest.php +++ b/module/Rest/test/Middleware/EmptyResponseImplicitOptionsMiddlewareFactoryTest.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Rest\Middleware; use Laminas\Diactoros\Response\EmptyResponse; -use Mezzio\Router\Middleware\ImplicitOptionsMiddleware; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ResponseFactoryInterface; @@ -21,13 +20,6 @@ class EmptyResponseImplicitOptionsMiddlewareFactoryTest extends TestCase $this->factory = new EmptyResponseImplicitOptionsMiddlewareFactory(); } - #[Test] - public function serviceIsCreated(): void - { - $instance = ($this->factory)(); - self::assertInstanceOf(ImplicitOptionsMiddleware::class, $instance); - } - #[Test] public function responsePrototypeIsEmptyResponse(): void { diff --git a/phpstan.neon b/phpstan.neon index 72c5ea6d..7b2a4718 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -10,7 +10,7 @@ parameters: - config - docker/config symfony: - console_application_loader: 'config/cli-app.php' + consoleApplicationLoader: 'config/cli-app.php' doctrine: repositoryClass: Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository objectManagerLoader: 'config/entity-manager.php' From 781c083c9fb461bf7359a381a841bf0b8f9be992 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 12 Nov 2024 08:37:22 +0100 Subject: [PATCH 46/80] Add new geolocatio-country-code redirect condition type --- composer.json | 2 +- .../src/RedirectRule/RedirectRuleHandler.php | 3 +++ .../RedirectRule/Entity/RedirectCondition.php | 22 +++++++++++++++++-- .../Model/RedirectConditionType.php | 1 + .../Entity/RedirectConditionTest.php | 18 +++++++++++++++ 5 files changed, 43 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 88e94946..76d94a07 100644 --- a/composer.json +++ b/composer.json @@ -49,7 +49,7 @@ "shlinkio/shlink-event-dispatcher": "^4.1", "shlinkio/shlink-importer": "^5.3.2", "shlinkio/shlink-installer": "^9.2", - "shlinkio/shlink-ip-geolocation": "^4.1", + "shlinkio/shlink-ip-geolocation": "dev-main#fadae5d as 4.2", "shlinkio/shlink-json": "^1.1", "spiral/roadrunner": "^2024.1", "spiral/roadrunner-cli": "^2.6", diff --git a/module/CLI/src/RedirectRule/RedirectRuleHandler.php b/module/CLI/src/RedirectRule/RedirectRuleHandler.php index 924876fc..f72d1ed0 100644 --- a/module/CLI/src/RedirectRule/RedirectRuleHandler.php +++ b/module/CLI/src/RedirectRule/RedirectRuleHandler.php @@ -111,6 +111,9 @@ class RedirectRuleHandler implements RedirectRuleHandlerInterface RedirectConditionType::IP_ADDRESS => RedirectCondition::forIpAddress( $this->askMandatory('IP address, CIDR block or wildcard-pattern (1.2.*.*)', $io), ), + RedirectConditionType::GEOLOCATION_COUNTRY_CODE => RedirectCondition::forGeolocationCountryCode( + $this->askMandatory('Country code to match?', $io), + ) }; $continue = $io->confirm('Do you want to add another condition?'); diff --git a/module/Core/src/RedirectRule/Entity/RedirectCondition.php b/module/Core/src/RedirectRule/Entity/RedirectCondition.php index 99f5fb9c..affa994a 100644 --- a/module/Core/src/RedirectRule/Entity/RedirectCondition.php +++ b/module/Core/src/RedirectRule/Entity/RedirectCondition.php @@ -9,6 +9,7 @@ use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType; use Shlinkio\Shlink\Core\RedirectRule\Model\Validation\RedirectRulesInputFilter; use Shlinkio\Shlink\Core\Util\IpAddressUtils; +use Shlinkio\Shlink\IpGeolocation\Model\Location; use function Shlinkio\Shlink\Core\acceptLanguageToLocales; use function Shlinkio\Shlink\Core\ArrayUtils\some; @@ -16,7 +17,7 @@ use function Shlinkio\Shlink\Core\ipAddressFromRequest; use function Shlinkio\Shlink\Core\normalizeLocale; use function Shlinkio\Shlink\Core\splitLocale; use function sprintf; -use function strtolower; +use function strcasecmp; use function trim; class RedirectCondition extends AbstractEntity implements JsonSerializable @@ -52,6 +53,11 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable return new self(RedirectConditionType::IP_ADDRESS, $ipAddressPattern); } + public static function forGeolocationCountryCode(string $countryCode): self + { + return new self(RedirectConditionType::GEOLOCATION_COUNTRY_CODE, $countryCode); + } + public static function fromRawData(array $rawData): self { $type = RedirectConditionType::from($rawData[RedirectRulesInputFilter::CONDITION_TYPE]); @@ -71,6 +77,7 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable RedirectConditionType::LANGUAGE => $this->matchesLanguage($request), RedirectConditionType::DEVICE => $this->matchesDevice($request), RedirectConditionType::IP_ADDRESS => $this->matchesRemoteIpAddress($request), + RedirectConditionType::GEOLOCATION_COUNTRY_CODE => $this->matchesGeolocationCountryCode($request), }; } @@ -109,7 +116,7 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable private function matchesDevice(ServerRequestInterface $request): bool { $device = DeviceType::matchFromUserAgent($request->getHeaderLine('User-Agent')); - return $device !== null && $device->value === strtolower($this->matchValue); + return $device !== null && $device->value === $this->matchValue; } private function matchesRemoteIpAddress(ServerRequestInterface $request): bool @@ -118,6 +125,16 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable return $remoteAddress !== null && IpAddressUtils::ipAddressMatchesGroups($remoteAddress, [$this->matchValue]); } + private function matchesGeolocationCountryCode(ServerRequestInterface $request): bool + { + $geolocation = $request->getAttribute(Location::class); + if (!($geolocation instanceof Location)) { + return false; + } + + return strcasecmp($geolocation->countryCode, $this->matchValue) === 0; + } + public function jsonSerialize(): array { return [ @@ -138,6 +155,7 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable $this->matchValue, ), RedirectConditionType::IP_ADDRESS => sprintf('IP address matches %s', $this->matchValue), + RedirectConditionType::GEOLOCATION_COUNTRY_CODE => sprintf('country code is %s', $this->matchValue), }; } } diff --git a/module/Core/src/RedirectRule/Model/RedirectConditionType.php b/module/Core/src/RedirectRule/Model/RedirectConditionType.php index 891a8ccc..ed587ffa 100644 --- a/module/Core/src/RedirectRule/Model/RedirectConditionType.php +++ b/module/Core/src/RedirectRule/Model/RedirectConditionType.php @@ -8,4 +8,5 @@ enum RedirectConditionType: string case LANGUAGE = 'language'; case QUERY_PARAM = 'query-param'; case IP_ADDRESS = 'ip-address'; + case GEOLOCATION_COUNTRY_CODE = 'geolocation-country-code'; } diff --git a/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php b/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php index b31d1fd3..81b69fe5 100644 --- a/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php +++ b/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php @@ -3,12 +3,14 @@ namespace ShlinkioTest\Shlink\Core\RedirectRule\Entity; use Laminas\Diactoros\ServerRequestFactory; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory; use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\RedirectRule\Entity\RedirectCondition; +use Shlinkio\Shlink\IpGeolocation\Model\Location; use const ShlinkioTest\Shlink\ANDROID_USER_AGENT; use const ShlinkioTest\Shlink\DESKTOP_USER_AGENT; @@ -93,4 +95,20 @@ class RedirectConditionTest extends TestCase self::assertEquals($expected, $result); } + + #[Test, DataProvider('provideVisits')] + public function matchesGeolocationCountryCode(Location|null $location, string $countryCodeToMatch, bool $expected): void + { + $request = ServerRequestFactory::fromGlobals()->withAttribute(Location::class, $location); + $result = RedirectCondition::forGeolocationCountryCode($countryCodeToMatch)->matchesRequest($request); + + self::assertEquals($expected, $result); + } + public static function provideVisits(): iterable + { + yield 'no location' => [null, 'US', false]; + yield 'non-matching location' => [new Location(countryCode: 'ES'), 'US', false]; + yield 'matching location' => [new Location(countryCode: 'US'), 'US', true]; + yield 'matching case-insensitive' => [new Location(countryCode: 'US'), 'us', true]; + } } From b5b5f92eda064923ec999e25b33f25137f3ab957 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 12 Nov 2024 09:52:09 +0100 Subject: [PATCH 47/80] Add validation for country-code redirect conditions --- .../definitions/SetShortUrlRedirectRule.json | 2 +- .../Model/RedirectConditionType.php | 37 +++++++++++++++++++ .../Validation/RedirectRulesInputFilter.php | 11 ++---- 3 files changed, 41 insertions(+), 9 deletions(-) diff --git a/docs/swagger/definitions/SetShortUrlRedirectRule.json b/docs/swagger/definitions/SetShortUrlRedirectRule.json index 00074acf..5ff6371c 100644 --- a/docs/swagger/definitions/SetShortUrlRedirectRule.json +++ b/docs/swagger/definitions/SetShortUrlRedirectRule.json @@ -15,7 +15,7 @@ "properties": { "type": { "type": "string", - "enum": ["device", "language", "query-param", "ip-address"], + "enum": ["device", "language", "query-param", "ip-address", "geolocation-country-code"], "description": "The type of the condition, which will determine the logic used to match it" }, "matchKey": { diff --git a/module/Core/src/RedirectRule/Model/RedirectConditionType.php b/module/Core/src/RedirectRule/Model/RedirectConditionType.php index ed587ffa..73f11a27 100644 --- a/module/Core/src/RedirectRule/Model/RedirectConditionType.php +++ b/module/Core/src/RedirectRule/Model/RedirectConditionType.php @@ -2,6 +2,12 @@ namespace Shlinkio\Shlink\Core\RedirectRule\Model; +use Shlinkio\Shlink\Core\Model\DeviceType; +use Shlinkio\Shlink\Core\Util\IpAddressUtils; + +use function Shlinkio\Shlink\Core\ArrayUtils\contains; +use function Shlinkio\Shlink\Core\enumValues; + enum RedirectConditionType: string { case DEVICE = 'device'; @@ -9,4 +15,35 @@ enum RedirectConditionType: string case QUERY_PARAM = 'query-param'; case IP_ADDRESS = 'ip-address'; case GEOLOCATION_COUNTRY_CODE = 'geolocation-country-code'; + + /** + * Tells if a value is valid for the condition type + */ + public function isValid(string $value): bool + { + return match ($this) { + RedirectConditionType::DEVICE => contains($value, enumValues(DeviceType::class)), + // RedirectConditionType::LANGUAGE => TODO Validate at least format, + RedirectConditionType::IP_ADDRESS => IpAddressUtils::isStaticIpCidrOrWildcard($value), + RedirectConditionType::GEOLOCATION_COUNTRY_CODE => contains($value, [ + 'AF', 'AX', 'AL', 'DZ', 'AS', 'AD', 'AO', 'AI', 'AQ', 'AG', 'AR', 'AM', 'AW', 'AU', 'AT', 'AZ', + 'BS', 'BH', 'BD', 'BB', 'BY', 'BE', 'BZ', 'BJ', 'BM', 'BT', 'BO', 'BQ', 'BA', 'BW', 'BV', 'BR', + 'IO', 'BN', 'BG', 'BF', 'BI', 'CV', 'KH', 'CM', 'CA', 'KY', 'CF', 'TD', 'CL', 'CN', 'CX', 'CC', + 'CO', 'KM', 'CG', 'CD', 'CK', 'CR', 'CI', 'HR', 'CU', 'CW', 'CY', 'CZ', 'DK', 'DJ', 'DM', 'DO', + 'EC', 'EG', 'SV', 'GQ', 'ER', 'EE', 'SZ', 'ET', 'FK', 'FO', 'FJ', 'FI', 'FR', 'GF', 'PF', 'TF', + 'GA', 'GM', 'GE', 'DE', 'GH', 'GI', 'GR', 'GL', 'GD', 'GP', 'GU', 'GT', 'GG', 'GN', 'GW', 'GY', + 'HT', 'HM', 'VA', 'HN', 'HK', 'HU', 'IS', 'IN', 'ID', 'IR', 'IQ', 'IE', 'IM', 'IL', 'IT', 'JM', + 'JP', 'JE', 'JO', 'KZ', 'KE', 'KI', 'KP', 'KR', 'KW', 'KG', 'LA', 'LV', 'LB', 'LS', 'LR', 'LY', + 'LI', 'LT', 'LU', 'MO', 'MG', 'MW', 'MY', 'MV', 'ML', 'MT', 'MH', 'MQ', 'MR', 'MU', 'YT', 'MX', + 'FM', 'MD', 'MC', 'MN', 'ME', 'MS', 'MA', 'MZ', 'MM', 'NA', 'NR', 'NP', 'NL', 'NC', 'NZ', 'NI', + 'NE', 'NG', 'NU', 'NF', 'MK', 'MP', 'NO', 'OM', 'PK', 'PW', 'PS', 'PA', 'PG', 'PY', 'PE', 'PH', + 'PN', 'PL', 'PT', 'PR', 'QA', 'RE', 'RO', 'RU', 'RW', 'BL', 'SH', 'KN', 'LC', 'MF', 'PM', 'VC', + 'WS', 'SM', 'ST', 'SA', 'SN', 'RS', 'SC', 'SL', 'SG', 'SX', 'SK', 'SI', 'SB', 'SO', 'ZA', 'GS', + 'SS', 'ES', 'LK', 'SD', 'SR', 'SJ', 'SE', 'CH', 'SY', 'TW', 'TJ', 'TZ', 'TH', 'TL', 'TG', 'TK', + 'TO', 'TT', 'TN', 'TR', 'TM', 'TC', 'TV', 'UG', 'UA', 'AE', 'GB', 'US', 'UM', 'UY', 'UZ', 'VU', + 'VE', 'VN', 'VG', 'VI', 'WF', 'EH', 'YE', 'ZM', 'ZW', + ]), + default => true, + }; + } } diff --git a/module/Core/src/RedirectRule/Model/Validation/RedirectRulesInputFilter.php b/module/Core/src/RedirectRule/Model/Validation/RedirectRulesInputFilter.php index c2fee661..42520a97 100644 --- a/module/Core/src/RedirectRule/Model/Validation/RedirectRulesInputFilter.php +++ b/module/Core/src/RedirectRule/Model/Validation/RedirectRulesInputFilter.php @@ -9,12 +9,9 @@ use Laminas\InputFilter\InputFilter; use Laminas\Validator\Callback; use Laminas\Validator\InArray; use Shlinkio\Shlink\Common\Validation\InputFactory; -use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter; -use Shlinkio\Shlink\Core\Util\IpAddressUtils; -use function Shlinkio\Shlink\Core\ArrayUtils\contains; use function Shlinkio\Shlink\Core\enumValues; /** @extends InputFilter */ @@ -80,11 +77,9 @@ class RedirectRulesInputFilter extends InputFilter $value = InputFactory::basic(self::CONDITION_MATCH_VALUE, required: true); $value->getValidatorChain()->attach(new Callback( - fn (string $value, array $context) => match ($context[self::CONDITION_TYPE]) { - RedirectConditionType::DEVICE->value => contains($value, enumValues(DeviceType::class)), - RedirectConditionType::IP_ADDRESS->value => IpAddressUtils::isStaticIpCidrOrWildcard($value), - // RedirectConditionType::LANGUAGE->value => TODO, - default => true, + function (string $value, array $context): bool { + $conditionType = RedirectConditionType::tryFrom($context[self::CONDITION_TYPE]); + return $conditionType === null || $conditionType->isValid($value); }, )); $redirectConditionInputFilter->add($value); From f2371b612424e191e75bf97f81ece65956804806 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 13 Nov 2024 10:00:36 +0100 Subject: [PATCH 48/80] Update RedirectRuleHandlerTest --- module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php | 5 +++++ .../test/RedirectRule/Entity/RedirectConditionTest.php | 7 +++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php b/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php index 18713e00..99a2167b 100644 --- a/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php +++ b/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php @@ -117,6 +117,7 @@ class RedirectRuleHandlerTest extends TestCase 'Query param name?' => 'foo', 'Query param value?' => 'bar', 'IP address, CIDR block or wildcard-pattern (1.2.*.*)' => '1.2.3.4', + 'Country code to match?' => 'FR', default => '', }, ); @@ -165,6 +166,10 @@ class RedirectRuleHandlerTest extends TestCase true, ]; yield 'IP address' => [RedirectConditionType::IP_ADDRESS, [RedirectCondition::forIpAddress('1.2.3.4')]]; + yield 'Geolocation country code' => [ + RedirectConditionType::GEOLOCATION_COUNTRY_CODE, + [RedirectCondition::forGeolocationCountryCode('FR')], + ]; } #[Test] diff --git a/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php b/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php index 81b69fe5..895b8236 100644 --- a/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php +++ b/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php @@ -97,8 +97,11 @@ class RedirectConditionTest extends TestCase } #[Test, DataProvider('provideVisits')] - public function matchesGeolocationCountryCode(Location|null $location, string $countryCodeToMatch, bool $expected): void - { + public function matchesGeolocationCountryCode( + Location|null $location, + string $countryCodeToMatch, + bool $expected, + ): void { $request = ServerRequestFactory::fromGlobals()->withAttribute(Location::class, $location); $result = RedirectCondition::forGeolocationCountryCode($countryCodeToMatch)->matchesRequest($request); From 4619ebd0145e508ff8ba75a08d05601eef4317df Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 14 Nov 2024 08:20:20 +0100 Subject: [PATCH 49/80] After tracking a visit, set its location in the request as attribute --- module/Core/src/Action/AbstractTrackingAction.php | 8 ++++++-- module/Core/src/Action/RedirectAction.php | 3 +-- .../src/RedirectRule/Entity/RedirectCondition.php | 4 +++- .../Middleware/ExtraPathRedirectMiddleware.php | 9 +++++++-- .../RedirectRule/Entity/RedirectConditionTest.php | 13 ++++++++++++- 5 files changed, 29 insertions(+), 8 deletions(-) diff --git a/module/Core/src/Action/AbstractTrackingAction.php b/module/Core/src/Action/AbstractTrackingAction.php index 78eebc05..b7ddb69a 100644 --- a/module/Core/src/Action/AbstractTrackingAction.php +++ b/module/Core/src/Action/AbstractTrackingAction.php @@ -15,6 +15,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface; +use Shlinkio\Shlink\IpGeolocation\Model\Location; abstract class AbstractTrackingAction implements MiddlewareInterface, RequestMethodInterface { @@ -30,9 +31,12 @@ abstract class AbstractTrackingAction implements MiddlewareInterface, RequestMet try { $shortUrl = $this->urlResolver->resolveEnabledShortUrl($identifier); - $this->requestTracker->trackIfApplicable($shortUrl, $request); + $visit = $this->requestTracker->trackIfApplicable($shortUrl, $request); - return $this->createSuccessResp($shortUrl, $request); + return $this->createSuccessResp( + $shortUrl, + $request->withAttribute(Location::class, $visit?->getVisitLocation()), + ); } catch (ShortUrlNotFoundException) { return $this->createErrorResp($request, $handler); } diff --git a/module/Core/src/Action/RedirectAction.php b/module/Core/src/Action/RedirectAction.php index 942cf550..a929f290 100644 --- a/module/Core/src/Action/RedirectAction.php +++ b/module/Core/src/Action/RedirectAction.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Action; -use Fig\Http\Message\StatusCodeInterface; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; @@ -13,7 +12,7 @@ use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface; use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface; -class RedirectAction extends AbstractTrackingAction implements StatusCodeInterface +class RedirectAction extends AbstractTrackingAction { public function __construct( ShortUrlResolverInterface $urlResolver, diff --git a/module/Core/src/RedirectRule/Entity/RedirectCondition.php b/module/Core/src/RedirectRule/Entity/RedirectCondition.php index affa994a..3782f0ef 100644 --- a/module/Core/src/RedirectRule/Entity/RedirectCondition.php +++ b/module/Core/src/RedirectRule/Entity/RedirectCondition.php @@ -9,6 +9,7 @@ use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType; use Shlinkio\Shlink\Core\RedirectRule\Model\Validation\RedirectRulesInputFilter; use Shlinkio\Shlink\Core\Util\IpAddressUtils; +use Shlinkio\Shlink\Core\Visit\Entity\VisitLocation; use Shlinkio\Shlink\IpGeolocation\Model\Location; use function Shlinkio\Shlink\Core\acceptLanguageToLocales; @@ -128,7 +129,8 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable private function matchesGeolocationCountryCode(ServerRequestInterface $request): bool { $geolocation = $request->getAttribute(Location::class); - if (!($geolocation instanceof Location)) { + // TODO We should eventually rely on `Location` type only + if (! ($geolocation instanceof Location) && ! ($geolocation instanceof VisitLocation)) { return false; } diff --git a/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php b/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php index 4a02f6e9..2b4ac7cc 100644 --- a/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php +++ b/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php @@ -17,6 +17,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface; use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface; +use Shlinkio\Shlink\IpGeolocation\Model\Location; use function array_slice; use function count; @@ -73,9 +74,13 @@ readonly class ExtraPathRedirectMiddleware implements MiddlewareInterface try { $shortUrl = $this->resolver->resolveEnabledShortUrl($identifier); - $this->requestTracker->trackIfApplicable($shortUrl, $request); + $visit = $this->requestTracker->trackIfApplicable($shortUrl, $request); - $longUrl = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, $request, $extraPath); + $longUrl = $this->redirectionBuilder->buildShortUrlRedirect( + $shortUrl, + $request->withAttribute(Location::class, $visit?->getVisitLocation()), + $extraPath, + ); return $this->redirectResponseHelper->buildRedirectResponse($longUrl); } catch (ShortUrlNotFoundException) { if ($extraPath === null || ! $this->urlShortenerOptions->multiSegmentSlugsEnabled) { diff --git a/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php b/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php index 895b8236..179d35e9 100644 --- a/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php +++ b/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php @@ -10,6 +10,7 @@ use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory; use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\RedirectRule\Entity\RedirectCondition; +use Shlinkio\Shlink\Core\Visit\Entity\VisitLocation; use Shlinkio\Shlink\IpGeolocation\Model\Location; use const ShlinkioTest\Shlink\ANDROID_USER_AGENT; @@ -98,7 +99,7 @@ class RedirectConditionTest extends TestCase #[Test, DataProvider('provideVisits')] public function matchesGeolocationCountryCode( - Location|null $location, + Location|VisitLocation|null $location, string $countryCodeToMatch, bool $expected, ): void { @@ -113,5 +114,15 @@ class RedirectConditionTest extends TestCase yield 'non-matching location' => [new Location(countryCode: 'ES'), 'US', false]; yield 'matching location' => [new Location(countryCode: 'US'), 'US', true]; yield 'matching case-insensitive' => [new Location(countryCode: 'US'), 'us', true]; + yield 'matching visit location' => [ + VisitLocation::fromGeolocation(new Location(countryCode: 'US')), + 'US', + true, + ]; + yield 'matching visit case-insensitive' => [ + VisitLocation::fromGeolocation(new Location(countryCode: 'es')), + 'ES', + true, + ]; } } From 51d838870d77011f761006f83cb0cee2e3c1d2a2 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 14 Nov 2024 09:14:17 +0100 Subject: [PATCH 50/80] Add reference to ISO 3166-1 alpha-2 country codes wikipedia page --- module/Core/src/RedirectRule/Model/RedirectConditionType.php | 1 + 1 file changed, 1 insertion(+) diff --git a/module/Core/src/RedirectRule/Model/RedirectConditionType.php b/module/Core/src/RedirectRule/Model/RedirectConditionType.php index 73f11a27..6e685709 100644 --- a/module/Core/src/RedirectRule/Model/RedirectConditionType.php +++ b/module/Core/src/RedirectRule/Model/RedirectConditionType.php @@ -26,6 +26,7 @@ enum RedirectConditionType: string // RedirectConditionType::LANGUAGE => TODO Validate at least format, RedirectConditionType::IP_ADDRESS => IpAddressUtils::isStaticIpCidrOrWildcard($value), RedirectConditionType::GEOLOCATION_COUNTRY_CODE => contains($value, [ + // List of ISO 3166-1 alpha-2 two-letter country codes https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2 'AF', 'AX', 'AL', 'DZ', 'AS', 'AD', 'AO', 'AI', 'AQ', 'AG', 'AR', 'AM', 'AW', 'AU', 'AT', 'AZ', 'BS', 'BH', 'BD', 'BB', 'BY', 'BE', 'BZ', 'BJ', 'BM', 'BT', 'BO', 'BQ', 'BA', 'BW', 'BV', 'BR', 'IO', 'BN', 'BG', 'BF', 'BI', 'CV', 'KH', 'CM', 'CA', 'KY', 'CF', 'TD', 'CL', 'CN', 'CX', 'CC', From fd34332e69e6f778fff939844fece0a6a9943eae Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 14 Nov 2024 09:17:41 +0100 Subject: [PATCH 51/80] Improve ExtraPathRedirectMiddlewareTest --- CHANGELOG.md | 4 ++++ .../Middleware/ExtraPathRedirectMiddlewareTest.php | 7 ++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 032be60e..c0fee24b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this This change applies both to the `GET /short-urls` endpoint, via the `domain` query parameter, and the `short-url:list` console command, via the `--domain`|`-d` flag. +* [#1774](https://github.com/shlinkio/shlink/issues/1774) Add new geolocation redirect rules for the dynamic redirects system. + + * `geolocation-country-code`: Allows to perform redirections based on the ISO 3166-1 alpha-2 two-letter country code resolved while geolocating the visitor. + ### Changed * [#2193](https://github.com/shlinkio/shlink/issues/2193) API keys are now hashed using SHA256, instead of being saved in plain text. diff --git a/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php b/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php index 85168020..2e35c1a4 100644 --- a/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php +++ b/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php @@ -9,6 +9,7 @@ use Laminas\Diactoros\ServerRequestFactory; use Laminas\Diactoros\Uri; use Mezzio\Router\Route; use Mezzio\Router\RouteResult; +use PHPUnit\Framework\Assert; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; @@ -26,6 +27,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface; use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface; +use Shlinkio\Shlink\IpGeolocation\Model\Location; use function Laminas\Stratigility\middleware; use function str_starts_with; @@ -153,7 +155,10 @@ class ExtraPathRedirectMiddlewareTest extends TestCase ); $this->redirectionBuilder->expects($this->once())->method('buildShortUrlRedirect')->with( $shortUrl, - $this->isInstanceOf(ServerRequestInterface::class), + $this->callback(function (ServerRequestInterface $req) { + Assert::assertArrayHasKey(Location::class, $req->getAttributes()); + return true; + }), $expectedExtraPath, )->willReturn('the_built_long_url'); $this->redirectResponseHelper->expects($this->once())->method('buildRedirectResponse')->with( From 7ddb3e7a70c901618f190809201eb413a5f177e8 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 14 Nov 2024 09:40:10 +0100 Subject: [PATCH 52/80] Add tests covering country code validation --- .../Model/RedirectRulesDataTest.php | 24 +++++++++++++++++++ .../test-api/Action/SetRedirectRulesTest.php | 14 +++++++++++ 2 files changed, 38 insertions(+) diff --git a/module/Core/test/RedirectRule/Model/RedirectRulesDataTest.php b/module/Core/test/RedirectRule/Model/RedirectRulesDataTest.php index e71140cb..d0186faa 100644 --- a/module/Core/test/RedirectRule/Model/RedirectRulesDataTest.php +++ b/module/Core/test/RedirectRule/Model/RedirectRulesDataTest.php @@ -63,6 +63,18 @@ class RedirectRulesDataTest extends TestCase ], ], ]]])] + #[TestWith([['redirectRules' => [ + [ + 'longUrl' => 'https://example.com', + 'conditions' => [ + [ + 'type' => 'geolocation-country-code', + 'matchKey' => null, + 'matchValue' => 'not an country code', + ], + ], + ], + ]]])] public function throwsWhenProvidedDataIsInvalid(array $invalidData): void { $this->expectException(ValidationException::class); @@ -118,6 +130,18 @@ class RedirectRulesDataTest extends TestCase ], ], ]]], 'in-between IP wildcard pattern')] + #[TestWith([['redirectRules' => [ + [ + 'longUrl' => 'https://example.com', + 'conditions' => [ + [ + 'type' => 'geolocation-country-code', + 'matchKey' => null, + 'matchValue' => 'US', + ], + ], + ], + ]]], 'country code')] public function allowsValidDataToBeSet(array $validData): void { $result = RedirectRulesData::fromRawData($validData); diff --git a/module/Rest/test-api/Action/SetRedirectRulesTest.php b/module/Rest/test-api/Action/SetRedirectRulesTest.php index f096e411..6501ef13 100644 --- a/module/Rest/test-api/Action/SetRedirectRulesTest.php +++ b/module/Rest/test-api/Action/SetRedirectRulesTest.php @@ -96,6 +96,20 @@ class SetRedirectRulesTest extends ApiTestCase ], ], ]], 'invalid IP address')] + #[TestWith([[ + 'redirectRules' => [ + [ + 'longUrl' => 'https://example.com', + 'conditions' => [ + [ + 'type' => 'geolocation-country-code', + 'matchKey' => null, + 'matchValue' => 'not a country code', + ], + ], + ], + ], + ]], 'invalid country code')] public function errorIsReturnedWhenInvalidDataIsProvided(array $bodyPayload): void { $response = $this->callApiWithKey(self::METHOD_POST, '/short-urls/abc123/redirect-rules', [ From a6e09162725cee5f62d01a6024d2a61ada506d25 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 14 Nov 2024 09:58:53 +0100 Subject: [PATCH 53/80] Add support for city name dynamic redirects --- CHANGELOG.md | 1 + .../definitions/SetShortUrlRedirectRule.json | 9 ++++- .../src/RedirectRule/RedirectRuleHandler.php | 3 ++ .../RedirectRule/RedirectRuleHandlerTest.php | 5 +++ .../RedirectRule/Entity/RedirectCondition.php | 19 +++++++++++ .../Model/RedirectConditionType.php | 1 + .../Entity/RedirectConditionTest.php | 33 +++++++++++++++++-- 7 files changed, 68 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0fee24b..8e9555e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#1774](https://github.com/shlinkio/shlink/issues/1774) Add new geolocation redirect rules for the dynamic redirects system. * `geolocation-country-code`: Allows to perform redirections based on the ISO 3166-1 alpha-2 two-letter country code resolved while geolocating the visitor. + * `geolocation-city-name`: Allows to perform redirections based on the city name resolved while geolocating the visitor. ### Changed * [#2193](https://github.com/shlinkio/shlink/issues/2193) API keys are now hashed using SHA256, instead of being saved in plain text. diff --git a/docs/swagger/definitions/SetShortUrlRedirectRule.json b/docs/swagger/definitions/SetShortUrlRedirectRule.json index 5ff6371c..00f0a27b 100644 --- a/docs/swagger/definitions/SetShortUrlRedirectRule.json +++ b/docs/swagger/definitions/SetShortUrlRedirectRule.json @@ -15,7 +15,14 @@ "properties": { "type": { "type": "string", - "enum": ["device", "language", "query-param", "ip-address", "geolocation-country-code"], + "enum": [ + "device", + "language", + "query-param", + "ip-address", + "geolocation-country-code", + "geolocation-city-name" + ], "description": "The type of the condition, which will determine the logic used to match it" }, "matchKey": { diff --git a/module/CLI/src/RedirectRule/RedirectRuleHandler.php b/module/CLI/src/RedirectRule/RedirectRuleHandler.php index f72d1ed0..89f93833 100644 --- a/module/CLI/src/RedirectRule/RedirectRuleHandler.php +++ b/module/CLI/src/RedirectRule/RedirectRuleHandler.php @@ -113,6 +113,9 @@ class RedirectRuleHandler implements RedirectRuleHandlerInterface ), RedirectConditionType::GEOLOCATION_COUNTRY_CODE => RedirectCondition::forGeolocationCountryCode( $this->askMandatory('Country code to match?', $io), + ), + RedirectConditionType::GEOLOCATION_CITY_NAME => RedirectCondition::forGeolocationCityName( + $this->askMandatory('City name to match?', $io), ) }; diff --git a/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php b/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php index 99a2167b..eb78da61 100644 --- a/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php +++ b/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php @@ -118,6 +118,7 @@ class RedirectRuleHandlerTest extends TestCase 'Query param value?' => 'bar', 'IP address, CIDR block or wildcard-pattern (1.2.*.*)' => '1.2.3.4', 'Country code to match?' => 'FR', + 'City name to match?' => 'Los angeles', default => '', }, ); @@ -170,6 +171,10 @@ class RedirectRuleHandlerTest extends TestCase RedirectConditionType::GEOLOCATION_COUNTRY_CODE, [RedirectCondition::forGeolocationCountryCode('FR')], ]; + yield 'Geolocation city name' => [ + RedirectConditionType::GEOLOCATION_CITY_NAME, + [RedirectCondition::forGeolocationCityName('Los angeles')], + ]; } #[Test] diff --git a/module/Core/src/RedirectRule/Entity/RedirectCondition.php b/module/Core/src/RedirectRule/Entity/RedirectCondition.php index 3782f0ef..9468d582 100644 --- a/module/Core/src/RedirectRule/Entity/RedirectCondition.php +++ b/module/Core/src/RedirectRule/Entity/RedirectCondition.php @@ -59,6 +59,11 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable return new self(RedirectConditionType::GEOLOCATION_COUNTRY_CODE, $countryCode); } + public static function forGeolocationCityName(string $cityName): self + { + return new self(RedirectConditionType::GEOLOCATION_CITY_NAME, $cityName); + } + public static function fromRawData(array $rawData): self { $type = RedirectConditionType::from($rawData[RedirectRulesInputFilter::CONDITION_TYPE]); @@ -79,6 +84,7 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable RedirectConditionType::DEVICE => $this->matchesDevice($request), RedirectConditionType::IP_ADDRESS => $this->matchesRemoteIpAddress($request), RedirectConditionType::GEOLOCATION_COUNTRY_CODE => $this->matchesGeolocationCountryCode($request), + RedirectConditionType::GEOLOCATION_CITY_NAME => $this->matchesGeolocationCityName($request), }; } @@ -137,6 +143,18 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable return strcasecmp($geolocation->countryCode, $this->matchValue) === 0; } + private function matchesGeolocationCityName(ServerRequestInterface $request): bool + { + $geolocation = $request->getAttribute(Location::class); + // TODO We should eventually rely on `Location` type only + if (! ($geolocation instanceof Location) && ! ($geolocation instanceof VisitLocation)) { + return false; + } + + $cityName = $geolocation instanceof Location ? $geolocation->city : $geolocation->cityName; + return strcasecmp($cityName, $this->matchValue) === 0; + } + public function jsonSerialize(): array { return [ @@ -158,6 +176,7 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable ), RedirectConditionType::IP_ADDRESS => sprintf('IP address matches %s', $this->matchValue), RedirectConditionType::GEOLOCATION_COUNTRY_CODE => sprintf('country code is %s', $this->matchValue), + RedirectConditionType::GEOLOCATION_CITY_NAME => sprintf('city name is %s', $this->matchValue), }; } } diff --git a/module/Core/src/RedirectRule/Model/RedirectConditionType.php b/module/Core/src/RedirectRule/Model/RedirectConditionType.php index 6e685709..efc314f9 100644 --- a/module/Core/src/RedirectRule/Model/RedirectConditionType.php +++ b/module/Core/src/RedirectRule/Model/RedirectConditionType.php @@ -15,6 +15,7 @@ enum RedirectConditionType: string case QUERY_PARAM = 'query-param'; case IP_ADDRESS = 'ip-address'; case GEOLOCATION_COUNTRY_CODE = 'geolocation-country-code'; + case GEOLOCATION_CITY_NAME = 'geolocation-city-name'; /** * Tells if a value is valid for the condition type diff --git a/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php b/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php index 179d35e9..d22b632d 100644 --- a/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php +++ b/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php @@ -97,7 +97,7 @@ class RedirectConditionTest extends TestCase self::assertEquals($expected, $result); } - #[Test, DataProvider('provideVisits')] + #[Test, DataProvider('provideVisitsWithCountry')] public function matchesGeolocationCountryCode( Location|VisitLocation|null $location, string $countryCodeToMatch, @@ -108,7 +108,7 @@ class RedirectConditionTest extends TestCase self::assertEquals($expected, $result); } - public static function provideVisits(): iterable + public static function provideVisitsWithCountry(): iterable { yield 'no location' => [null, 'US', false]; yield 'non-matching location' => [new Location(countryCode: 'ES'), 'US', false]; @@ -125,4 +125,33 @@ class RedirectConditionTest extends TestCase true, ]; } + + #[Test, DataProvider('provideVisitsWithCity')] + public function matchesGeolocationCityName( + Location|VisitLocation|null $location, + string $cityNameToMatch, + bool $expected, + ): void { + $request = ServerRequestFactory::fromGlobals()->withAttribute(Location::class, $location); + $result = RedirectCondition::forGeolocationCityName($cityNameToMatch)->matchesRequest($request); + + self::assertEquals($expected, $result); + } + public static function provideVisitsWithCity(): iterable + { + yield 'no location' => [null, 'New York', false]; + yield 'non-matching location' => [new Location(city: 'Los Angeles'), 'New York', false]; + yield 'matching location' => [new Location(city: 'Madrid'), 'Madrid', true]; + yield 'matching case-insensitive' => [new Location(city: 'Los Angeles'), 'los angeles', true]; + yield 'matching visit location' => [ + VisitLocation::fromGeolocation(new Location(city: 'New York')), + 'New York', + true, + ]; + yield 'matching visit case-insensitive' => [ + VisitLocation::fromGeolocation(new Location(city: 'barcelona')), + 'Barcelona', + true, + ]; + } } From 4a0b7e3fc9bac983c23c52a6e1bf1d5ea540557d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 14 Nov 2024 14:48:18 +0100 Subject: [PATCH 54/80] Refactor Visitor model and allow a Location object to be passed to it --- .../CLI/src/GeoLite/GeolocationDbUpdater.php | 2 +- .../Domain/GetDomainVisitsCommandTest.php | 2 +- .../ShortUrl/GetShortUrlVisitsCommandTest.php | 2 +- .../Command/Tag/GetTagVisitsCommandTest.php | 2 +- .../Visit/GetNonOrphanVisitsCommandTest.php | 2 +- .../Visit/GetOrphanVisitsCommandTest.php | 2 +- .../Command/Visit/LocateVisitsCommandTest.php | 6 +- module/Core/functions/functions.php | 6 ++ .../src/Config/Options/TrackingOptions.php | 8 ++ module/Core/src/Visit/Entity/Visit.php | 2 +- module/Core/src/Visit/Model/Visitor.php | 87 ++++++++++--------- .../test/EventDispatcher/LocateVisitTest.php | 30 ++++--- .../test/Matomo/MatomoVisitSenderTest.php | 4 +- module/Core/test/Visit/Entity/VisitTest.php | 7 +- .../Geolocation/VisitToLocationHelperTest.php | 4 +- module/Core/test/Visit/Model/VisitorTest.php | 4 +- .../Rest/test-api/Fixtures/VisitsFixture.php | 48 ++++++---- 17 files changed, 131 insertions(+), 87 deletions(-) diff --git a/module/CLI/src/GeoLite/GeolocationDbUpdater.php b/module/CLI/src/GeoLite/GeolocationDbUpdater.php index 2abae05b..2a0fda3b 100644 --- a/module/CLI/src/GeoLite/GeolocationDbUpdater.php +++ b/module/CLI/src/GeoLite/GeolocationDbUpdater.php @@ -44,7 +44,7 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface callable|null $beforeDownload = null, callable|null $handleProgress = null, ): GeolocationResult { - if ($this->trackingOptions->disableTracking || $this->trackingOptions->disableIpTracking) { + if (! $this->trackingOptions->isGeolocationRelevant()) { return GeolocationResult::CHECK_SKIPPED; } diff --git a/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php b/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php index 6563abc0..e174a3b0 100644 --- a/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php +++ b/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php @@ -40,7 +40,7 @@ class GetDomainVisitsCommandTest extends TestCase public function outputIsProperlyGenerated(): void { $shortUrl = ShortUrl::createFake(); - $visit = Visit::forValidShortUrl($shortUrl, new Visitor('bar', 'foo', '', ''))->locate( + $visit = Visit::forValidShortUrl($shortUrl, Visitor::fromParams('bar', 'foo', ''))->locate( VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), ); $domain = 's.test'; diff --git a/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php b/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php index ba6735ba..a1905e38 100644 --- a/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php @@ -93,7 +93,7 @@ class GetShortUrlVisitsCommandTest extends TestCase #[Test] public function outputIsProperlyGenerated(): void { - $visit = Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor('bar', 'foo', '', ''))->locate( + $visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::fromParams('bar', 'foo', ''))->locate( VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), ); $shortCode = 'abc123'; diff --git a/module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php b/module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php index 9b79f509..08ca2cd3 100644 --- a/module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php +++ b/module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php @@ -40,7 +40,7 @@ class GetTagVisitsCommandTest extends TestCase public function outputIsProperlyGenerated(): void { $shortUrl = ShortUrl::createFake(); - $visit = Visit::forValidShortUrl($shortUrl, new Visitor('bar', 'foo', '', ''))->locate( + $visit = Visit::forValidShortUrl($shortUrl, Visitor::fromParams('bar', 'foo', ''))->locate( VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), ); $tag = 'abc123'; diff --git a/module/CLI/test/Command/Visit/GetNonOrphanVisitsCommandTest.php b/module/CLI/test/Command/Visit/GetNonOrphanVisitsCommandTest.php index 0462c2c0..4ebe780f 100644 --- a/module/CLI/test/Command/Visit/GetNonOrphanVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/GetNonOrphanVisitsCommandTest.php @@ -40,7 +40,7 @@ class GetNonOrphanVisitsCommandTest extends TestCase public function outputIsProperlyGenerated(): void { $shortUrl = ShortUrl::createFake(); - $visit = Visit::forValidShortUrl($shortUrl, new Visitor('bar', 'foo', '', ''))->locate( + $visit = Visit::forValidShortUrl($shortUrl, Visitor::fromParams('bar', 'foo', ''))->locate( VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), ); $this->visitsHelper->expects($this->once())->method('nonOrphanVisits')->withAnyParameters()->willReturn( diff --git a/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php b/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php index 29914b61..33a98448 100644 --- a/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php @@ -37,7 +37,7 @@ class GetOrphanVisitsCommandTest extends TestCase #[TestWith([['--type' => OrphanVisitType::BASE_URL->value], true])] public function outputIsProperlyGenerated(array $args, bool $includesType): void { - $visit = Visit::forBasePath(new Visitor('bar', 'foo', '', ''))->locate( + $visit = Visit::forBasePath(Visitor::fromParams('bar', 'foo', ''))->locate( VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), ); $this->visitsHelper->expects($this->once())->method('orphanVisits')->with($this->callback( diff --git a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php index b17ca369..0f24a603 100644 --- a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php @@ -63,8 +63,8 @@ class LocateVisitsCommandTest extends TestCase bool $expectWarningPrint, array $args, ): void { - $visit = Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor('', '', '1.2.3.4', '')); - $location = VisitLocation::fromGeolocation(Location::emptyInstance()); + $visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::fromParams('', '', '1.2.3.4')); + $location = VisitLocation::fromGeolocation(Location::empty()); $mockMethodBehavior = $this->invokeHelperMethods($visit, $location); $this->lock->method('acquire')->with($this->isFalse())->willReturn(true); @@ -134,7 +134,7 @@ class LocateVisitsCommandTest extends TestCase #[Test] public function errorWhileLocatingIpIsDisplayed(): void { - $visit = Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor('', '', '1.2.3.4', '')); + $visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::fromParams(remoteAddress: '1.2.3.4')); $location = VisitLocation::fromGeolocation(Location::emptyInstance()); $this->lock->method('acquire')->with($this->isFalse())->willReturn(true); diff --git a/module/Core/functions/functions.php b/module/Core/functions/functions.php index cb8c0a8c..1d989c75 100644 --- a/module/Core/functions/functions.php +++ b/module/Core/functions/functions.php @@ -18,6 +18,7 @@ use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; +use Shlinkio\Shlink\IpGeolocation\Model\Location; use function array_keys; use function array_map; @@ -289,3 +290,8 @@ function ipAddressFromRequest(ServerRequestInterface $request): string|null { return $request->getAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR); } + +function geolocationFromRequest(ServerRequestInterface $request): Location|null +{ + return $request->getAttribute(Location::class); +} diff --git a/module/Core/src/Config/Options/TrackingOptions.php b/module/Core/src/Config/Options/TrackingOptions.php index d238bb42..754978f9 100644 --- a/module/Core/src/Config/Options/TrackingOptions.php +++ b/module/Core/src/Config/Options/TrackingOptions.php @@ -59,4 +59,12 @@ final readonly class TrackingOptions { return $this->disableTrackParam !== null && array_key_exists($this->disableTrackParam, $query); } + + /** + * If IP address tracking is disabled, or tracking is disabled all together, then geolocation is not relevant + */ + public function isGeolocationRelevant(): bool + { + return ! $this->disableTracking && ! $this->disableIpTracking; + } } diff --git a/module/Core/src/Visit/Entity/Visit.php b/module/Core/src/Visit/Entity/Visit.php index d02d7298..4e3c48a8 100644 --- a/module/Core/src/Visit/Entity/Visit.php +++ b/module/Core/src/Visit/Entity/Visit.php @@ -64,7 +64,7 @@ class Visit extends AbstractEntity implements JsonSerializable type: $type, userAgent: $visitor->userAgent, referer: $visitor->referer, - potentialBot: $visitor->isPotentialBot(), + potentialBot: $visitor->potentialBot, remoteAddr: self::processAddress($visitor->remoteAddress, $anonymize), visitedUrl: $visitor->visitedUrl, ); diff --git a/module/Core/src/Visit/Model/Visitor.php b/module/Core/src/Visit/Model/Visitor.php index 493280ef..e13712e1 100644 --- a/module/Core/src/Visit/Model/Visitor.php +++ b/module/Core/src/Visit/Model/Visitor.php @@ -6,78 +6,85 @@ namespace Shlinkio\Shlink\Core\Visit\Model; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Core\Config\Options\TrackingOptions; +use Shlinkio\Shlink\IpGeolocation\Model\Location; +use function Shlinkio\Shlink\Core\geolocationFromRequest; use function Shlinkio\Shlink\Core\ipAddressFromRequest; use function Shlinkio\Shlink\Core\isCrawler; use function substr; -final class Visitor +final readonly class Visitor { public const USER_AGENT_MAX_LENGTH = 512; public const REFERER_MAX_LENGTH = 1024; public const REMOTE_ADDRESS_MAX_LENGTH = 256; public const VISITED_URL_MAX_LENGTH = 2048; - public readonly string $userAgent; - public readonly string $referer; - public readonly string $visitedUrl; - public readonly string|null $remoteAddress; - private bool $potentialBot; - - public function __construct(string $userAgent, string $referer, string|null $remoteAddress, string $visitedUrl) - { - $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 = $remoteAddress === null ? null : $this->cropToLength( - $remoteAddress, - self::REMOTE_ADDRESS_MAX_LENGTH, - ); - $this->potentialBot = isCrawler($userAgent); + private function __construct( + public string $userAgent, + public string $referer, + public string|null $remoteAddress, + public string $visitedUrl, + public bool $potentialBot, + public Location|null $geolocation, + ) { } - private function cropToLength(string $value, int $length): string + public static function fromParams( + string $userAgent = '', + string $referer = '', + string|null $remoteAddress = null, + string $visitedUrl = '', + Location|null $geolocation = null, + ): self { + return new self( + userAgent: self::cropToLength($userAgent, self::USER_AGENT_MAX_LENGTH), + referer: self::cropToLength($referer, self::REFERER_MAX_LENGTH), + remoteAddress: $remoteAddress === null + ? null + : self::cropToLength($remoteAddress, self::REMOTE_ADDRESS_MAX_LENGTH), + visitedUrl: self::cropToLength($visitedUrl, self::VISITED_URL_MAX_LENGTH), + potentialBot: isCrawler($userAgent), + geolocation: $geolocation, + ); + } + + private static function cropToLength(string $value, int $length): string { return substr($value, 0, $length); } public static function fromRequest(ServerRequestInterface $request): self { - return new self( - $request->getHeaderLine('User-Agent'), - $request->getHeaderLine('Referer'), - ipAddressFromRequest($request), - $request->getUri()->__toString(), + return self::fromParams( + userAgent: $request->getHeaderLine('User-Agent'), + referer: $request->getHeaderLine('Referer'), + remoteAddress: ipAddressFromRequest($request), + visitedUrl: $request->getUri()->__toString(), + geolocation: geolocationFromRequest($request), ); } public static function empty(): self { - return new self('', '', null, ''); + return self::fromParams(); } public static function botInstance(): self { - return new self('cf-facebook', '', null, ''); - } - - public function isPotentialBot(): bool - { - return $this->potentialBot; + return self::fromParams(userAgent: 'cf-facebook'); } public function normalizeForTrackingOptions(TrackingOptions $options): self { - $instance = new self( - $options->disableUaTracking ? '' : $this->userAgent, - $options->disableReferrerTracking ? '' : $this->referer, - $options->disableIpTracking ? null : $this->remoteAddress, - $this->visitedUrl, + return new self( + userAgent: $options->disableUaTracking ? '' : $this->userAgent, + referer: $options->disableReferrerTracking ? '' : $this->referer, + remoteAddress: $options->disableIpTracking ? null : $this->remoteAddress, + visitedUrl: $this->visitedUrl, + // Keep the fact that the visit was a potential bot, even if we no longer save the user agent + potentialBot: $this->potentialBot, + geolocation: $this->geolocation, ); - - // Keep the fact that the visit was a potential bot, even if we no longer save the user agent - $instance->potentialBot = $this->potentialBot; - - return $instance; } } diff --git a/module/Core/test/EventDispatcher/LocateVisitTest.php b/module/Core/test/EventDispatcher/LocateVisitTest.php index 80e3e318..b88bf470 100644 --- a/module/Core/test/EventDispatcher/LocateVisitTest.php +++ b/module/Core/test/EventDispatcher/LocateVisitTest.php @@ -72,7 +72,7 @@ class LocateVisitTest extends TestCase { $event = new UrlVisited('123'); $this->em->expects($this->once())->method('find')->with(Visit::class, '123')->willReturn( - Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor('', '', '1.2.3.4', '')), + Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::fromParams(remoteAddress: '1.2.3.4')), ); $this->em->expects($this->never())->method('flush'); $this->dbUpdater->expects($this->once())->method('databaseFileExists')->withAnyParameters()->willReturn(false); @@ -91,7 +91,7 @@ class LocateVisitTest extends TestCase { $event = new UrlVisited('123'); $this->em->expects($this->once())->method('find')->with(Visit::class, '123')->willReturn( - Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor('', '', '1.2.3.4', '')), + Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::fromParams(remoteAddress: '1.2.3.4')), ); $this->em->expects($this->never())->method('flush'); $this->dbUpdater->expects($this->once())->method('databaseFileExists')->withAnyParameters()->willReturn(true); @@ -112,7 +112,7 @@ class LocateVisitTest extends TestCase { $event = new UrlVisited('123'); $this->em->expects($this->once())->method('find')->with(Visit::class, '123')->willReturn( - Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor('', '', '1.2.3.4', '')), + Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::fromParams(remoteAddress: '1.2.3.4')), ); $this->em->expects($this->never())->method('flush'); $this->dbUpdater->expects($this->once())->method('databaseFileExists')->withAnyParameters()->willReturn(true); @@ -149,9 +149,11 @@ class LocateVisitTest extends TestCase { $shortUrl = ShortUrl::createFake(); - yield 'null IP' => [Visit::forValidShortUrl($shortUrl, new Visitor('', '', null, ''))]; - yield 'empty IP' => [Visit::forValidShortUrl($shortUrl, new Visitor('', '', '', ''))]; - yield 'localhost' => [Visit::forValidShortUrl($shortUrl, new Visitor('', '', IpAddress::LOCALHOST, ''))]; + yield 'null IP' => [Visit::forValidShortUrl($shortUrl, Visitor::empty())]; + yield 'empty IP' => [Visit::forValidShortUrl($shortUrl, Visitor::fromParams(remoteAddress: ''))]; + yield 'localhost' => [ + Visit::forValidShortUrl($shortUrl, Visitor::fromParams(remoteAddress: IpAddress::LOCALHOST)), + ]; } #[Test, DataProvider('provideIpAddresses')] @@ -181,15 +183,21 @@ class LocateVisitTest extends TestCase public static function provideIpAddresses(): iterable { yield 'no original IP address' => [ - Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor('', '', '1.2.3.4', '')), + Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::fromParams(remoteAddress: '1.2.3.4')), null, ]; yield 'original IP address' => [ - Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor('', '', '1.2.3.4', '')), + Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::fromParams(remoteAddress: '1.2.3.4')), + '1.2.3.4', + ]; + yield 'base url' => [Visit::forBasePath(Visitor::fromParams(remoteAddress: '1.2.3.4')), '1.2.3.4']; + yield 'invalid short url' => [ + Visit::forInvalidShortUrl(Visitor::fromParams(remoteAddress: '1.2.3.4')), + '1.2.3.4', + ]; + yield 'regular not found' => [ + Visit::forRegularNotFound(Visitor::fromParams(remoteAddress: '1.2.3.4')), '1.2.3.4', ]; - yield 'base url' => [Visit::forBasePath(new Visitor('', '', '1.2.3.4', '')), '1.2.3.4']; - yield 'invalid short url' => [Visit::forInvalidShortUrl(new Visitor('', '', '1.2.3.4', '')), '1.2.3.4']; - yield 'regular not found' => [Visit::forRegularNotFound(new Visitor('', '', '1.2.3.4', '')), '1.2.3.4']; } } diff --git a/module/Core/test/Matomo/MatomoVisitSenderTest.php b/module/Core/test/Matomo/MatomoVisitSenderTest.php index f78d0f33..0acccd1d 100644 --- a/module/Core/test/Matomo/MatomoVisitSenderTest.php +++ b/module/Core/test/Matomo/MatomoVisitSenderTest.php @@ -92,7 +92,7 @@ class MatomoVisitSenderTest extends TestCase '1.2.3.4', ['setCity', 'setCountry', 'setLatitude', 'setLongitude', 'setIp'], ]; - yield 'fallback IP' => [Visit::forBasePath(new Visitor('', '', '1.2.3.4', '')), null, ['setIp']]; + yield 'fallback IP' => [Visit::forBasePath(Visitor::fromParams(remoteAddress: '1.2.3.4')), null, ['setIp']]; } #[Test, DataProvider('provideUrlsToTrack')] @@ -117,7 +117,7 @@ class MatomoVisitSenderTest extends TestCase { yield 'orphan visit without visited URL' => [Visit::forBasePath(Visitor::empty()), '']; yield 'orphan visit with visited URL' => [ - Visit::forBasePath(new Visitor('', '', null, 'https://s.test/foo')), + Visit::forBasePath(Visitor::fromParams(visitedUrl: 'https://s.test/foo')), 'https://s.test/foo', ]; yield 'non-orphan visit' => [ diff --git a/module/Core/test/Visit/Entity/VisitTest.php b/module/Core/test/Visit/Entity/VisitTest.php index edb47a53..db23af97 100644 --- a/module/Core/test/Visit/Entity/VisitTest.php +++ b/module/Core/test/Visit/Entity/VisitTest.php @@ -22,7 +22,10 @@ class VisitTest extends TestCase #[Test, DataProvider('provideUserAgents')] public function isProperlyJsonSerialized(string $userAgent, bool $expectedToBePotentialBot): void { - $visit = Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor($userAgent, 'some site', '1.2.3.4', '')); + $visit = Visit::forValidShortUrl( + ShortUrl::createFake(), + Visitor::fromParams($userAgent, 'some site', '1.2.3.4'), + ); self::assertEquals([ 'referer' => 'some site', @@ -110,7 +113,7 @@ class VisitTest extends TestCase ): void { $visit = Visit::forValidShortUrl( ShortUrl::createFake(), - new Visitor('Chrome', 'some site', $address, ''), + Visitor::fromParams('Chrome', 'some site', $address), $anonymize, ); diff --git a/module/Core/test/Visit/Geolocation/VisitToLocationHelperTest.php b/module/Core/test/Visit/Geolocation/VisitToLocationHelperTest.php index 1f6b7f09..a9d8f3e5 100644 --- a/module/Core/test/Visit/Geolocation/VisitToLocationHelperTest.php +++ b/module/Core/test/Visit/Geolocation/VisitToLocationHelperTest.php @@ -42,7 +42,7 @@ class VisitToLocationHelperTest extends TestCase { yield [Visit::forBasePath(Visitor::empty()), IpCannotBeLocatedException::forEmptyAddress()]; yield [ - Visit::forBasePath(new Visitor('foo', 'bar', IpAddress::LOCALHOST, '')), + Visit::forBasePath(Visitor::fromParams('foo', 'bar', IpAddress::LOCALHOST)), IpCannotBeLocatedException::forLocalhost(), ]; } @@ -55,6 +55,6 @@ class VisitToLocationHelperTest extends TestCase $this->expectExceptionObject(IpCannotBeLocatedException::forError($e)); $this->ipLocationResolver->expects($this->once())->method('resolveIpLocation')->willThrowException($e); - $this->helper->resolveVisitLocation(Visit::forBasePath(new Visitor('foo', 'bar', '1.2.3.4', ''))); + $this->helper->resolveVisitLocation(Visit::forBasePath(Visitor::fromParams('foo', 'bar', '1.2.3.4'))); } } diff --git a/module/Core/test/Visit/Model/VisitorTest.php b/module/Core/test/Visit/Model/VisitorTest.php index 04e57179..25be7440 100644 --- a/module/Core/test/Visit/Model/VisitorTest.php +++ b/module/Core/test/Visit/Model/VisitorTest.php @@ -20,7 +20,7 @@ class VisitorTest extends TestCase #[Test, DataProvider('provideParams')] public function providedFieldsValuesAreCropped(array $params, array $expected): void { - $visitor = new Visitor(...$params); + $visitor = Visitor::fromParams(...$params); ['userAgent' => $userAgent, 'referer' => $referer, 'remoteAddress' => $remoteAddress] = $expected; self::assertEquals($userAgent, $visitor->userAgent); @@ -75,7 +75,7 @@ class VisitorTest extends TestCase #[Test] public function newNormalizedInstanceIsCreatedFromTrackingOptions(): void { - $visitor = new Visitor( + $visitor = Visitor::fromParams( self::generateRandomString(2000), self::generateRandomString(2000), self::generateRandomString(2000), diff --git a/module/Rest/test-api/Fixtures/VisitsFixture.php b/module/Rest/test-api/Fixtures/VisitsFixture.php index 9972e3a8..e10b6dab 100644 --- a/module/Rest/test-api/Fixtures/VisitsFixture.php +++ b/module/Rest/test-api/Fixtures/VisitsFixture.php @@ -23,43 +23,55 @@ class VisitsFixture extends AbstractFixture implements DependentFixtureInterface { /** @var ShortUrl $abcShortUrl */ $abcShortUrl = $this->getReference('abc123_short_url'); - $manager->persist( - Visit::forValidShortUrl($abcShortUrl, new Visitor('shlink-tests-agent', '', '44.55.66.77', '')), - ); $manager->persist(Visit::forValidShortUrl( $abcShortUrl, - new Visitor('shlink-tests-agent', 'https://google.com', '4.5.6.7', ''), + Visitor::fromParams(userAgent: 'shlink-tests-agent', remoteAddress: '44.55.66.77'), + )); + $manager->persist(Visit::forValidShortUrl( + $abcShortUrl, + Visitor::fromParams('shlink-tests-agent', 'https://google.com', '4.5.6.7'), + )); + $manager->persist(Visit::forValidShortUrl( + $abcShortUrl, + Visitor::fromParams(userAgent: 'shlink-tests-agent', remoteAddress: '1.2.3.4'), )); - $manager->persist(Visit::forValidShortUrl($abcShortUrl, new Visitor('shlink-tests-agent', '', '1.2.3.4', ''))); /** @var ShortUrl $defShortUrl */ $defShortUrl = $this->getReference('def456_short_url'); - $manager->persist( - Visit::forValidShortUrl($defShortUrl, new Visitor('cf-facebook', '', '127.0.0.1', '')), - ); - $manager->persist( - Visit::forValidShortUrl($defShortUrl, new Visitor('shlink-tests-agent', 'https://app.shlink.io', '', '')), - ); + $manager->persist(Visit::forValidShortUrl( + $defShortUrl, + Visitor::fromParams(userAgent: 'cf-facebook', remoteAddress: '127.0.0.1'), + )); + $manager->persist(Visit::forValidShortUrl( + $defShortUrl, + Visitor::fromParams('shlink-tests-agent', 'https://app.shlink.io', ''), + )); /** @var ShortUrl $ghiShortUrl */ $ghiShortUrl = $this->getReference('ghi789_short_url'); - $manager->persist(Visit::forValidShortUrl($ghiShortUrl, new Visitor('shlink-tests-agent', '', '1.2.3.4', ''))); - $manager->persist( - Visit::forValidShortUrl($ghiShortUrl, new Visitor('shlink-tests-agent', 'https://app.shlink.io', '', '')), - ); + $manager->persist(Visit::forValidShortUrl( + $ghiShortUrl, + Visitor::fromParams(userAgent: 'shlink-tests-agent', remoteAddress: '1.2.3.4'), + )); + $manager->persist(Visit::forValidShortUrl( + $ghiShortUrl, + Visitor::fromParams('shlink-tests-agent', 'https://app.shlink.io', ''), + )); $manager->persist($this->setVisitDate( - fn () => Visit::forBasePath(new Visitor('shlink-tests-agent', 'https://s.test', '1.2.3.4', '')), + fn () => Visit::forBasePath(Visitor::fromParams('shlink-tests-agent', 'https://s.test', '1.2.3.4')), '2020-01-01', )); $manager->persist($this->setVisitDate( fn () => Visit::forRegularNotFound( - new Visitor('shlink-tests-agent', 'https://s.test/foo/bar', '1.2.3.4', ''), + Visitor::fromParams('shlink-tests-agent', 'https://s.test/foo/bar', '1.2.3.4'), ), '2020-02-01', )); $manager->persist($this->setVisitDate( - fn () => Visit::forInvalidShortUrl(new Visitor('cf-facebook', 'https://s.test/foo', '1.2.3.4', 'foo.com')), + fn () => Visit::forInvalidShortUrl( + Visitor::fromParams('cf-facebook', 'https://s.test/foo', '1.2.3.4', 'foo.com'), + ), '2020-03-01', )); From b5ff5686517a8c81ad869f292cd6844bb6affd13 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 15 Nov 2024 08:51:57 +0100 Subject: [PATCH 55/80] Use IpGeolocationMiddleware to geolocate visitors instead of LocateVisit event --- .../autoload/middleware-pipeline.global.php | 6 +- config/autoload/routes.config.php | 3 + module/Core/config/dependencies.config.php | 10 + .../Core/config/event_dispatcher.config.php | 19 +- .../src/Action/AbstractTrackingAction.php | 8 +- .../Async/AbstractNotifyVisitListener.php | 4 +- .../Event/AbstractVisitEvent.php | 27 --- .../EventDispatcher/Event/ShortUrlCreated.php | 4 +- .../src/EventDispatcher/Event/UrlVisited.php | 20 +- .../EventDispatcher/Event/VisitLocated.php | 9 - .../Core/src/EventDispatcher/LocateVisit.php | 77 ------- .../Matomo/SendVisitToMatomo.php | 4 +- .../Middleware/IpGeolocationMiddleware.php | 58 +++++ .../ExtraPathRedirectMiddleware.php | 9 +- module/Core/src/Visit/Entity/Visit.php | 2 + .../test/EventDispatcher/LocateVisitTest.php | 203 ------------------ .../Matomo/SendVisitToMatomoTest.php | 10 +- .../Mercure/NotifyVisitToMercureTest.php | 10 +- .../RabbitMq/NotifyVisitToRabbitMqTest.php | 12 +- .../RedisPubSub/NotifyVisitToRedisTest.php | 6 +- .../ExtraPathRedirectMiddlewareTest.php | 7 +- 21 files changed, 130 insertions(+), 378 deletions(-) delete mode 100644 module/Core/src/EventDispatcher/Event/AbstractVisitEvent.php delete mode 100644 module/Core/src/EventDispatcher/Event/VisitLocated.php delete mode 100644 module/Core/src/EventDispatcher/LocateVisit.php create mode 100644 module/Core/src/Geolocation/Middleware/IpGeolocationMiddleware.php delete mode 100644 module/Core/test/EventDispatcher/LocateVisitTest.php diff --git a/config/autoload/middleware-pipeline.global.php b/config/autoload/middleware-pipeline.global.php index 99f71bce..63d19d4d 100644 --- a/config/autoload/middleware-pipeline.global.php +++ b/config/autoload/middleware-pipeline.global.php @@ -11,6 +11,7 @@ use RKA\Middleware\IpAddress; use Shlinkio\Shlink\Common\Middleware\AccessLogMiddleware; use Shlinkio\Shlink\Common\Middleware\ContentLengthMiddleware; use Shlinkio\Shlink\Common\Middleware\RequestIdMiddleware; +use Shlinkio\Shlink\Core\Geolocation\Middleware\IpGeolocationMiddleware; return [ @@ -67,8 +68,11 @@ return [ ], 'not-found' => [ 'middleware' => [ - // This middleware is in front of tracking actions explicitly. Putting here for orphan visits tracking + // These two middlewares are in front of other tracking actions. + // Putting them here for orphan visits tracking IpAddress::class, + IpGeolocationMiddleware::class, + Core\ErrorHandler\NotFoundTypeResolverMiddleware::class, Core\ShortUrl\Middleware\ExtraPathRedirectMiddleware::class, Core\ErrorHandler\NotFoundTrackerMiddleware::class, diff --git a/config/autoload/routes.config.php b/config/autoload/routes.config.php index da3d1778..1f5425b5 100644 --- a/config/autoload/routes.config.php +++ b/config/autoload/routes.config.php @@ -8,6 +8,7 @@ use Fig\Http\Message\RequestMethodInterface; use RKA\Middleware\IpAddress; use Shlinkio\Shlink\Core\Action as CoreAction; use Shlinkio\Shlink\Core\Config\EnvVars; +use Shlinkio\Shlink\Core\Geolocation\Middleware\IpGeolocationMiddleware; use Shlinkio\Shlink\Core\ShortUrl\Middleware\TrimTrailingSlashMiddleware; use Shlinkio\Shlink\Rest\Action; use Shlinkio\Shlink\Rest\ConfigProvider; @@ -88,6 +89,7 @@ return (static function (): array { 'path' => '/{shortCode}/track', 'middleware' => [ IpAddress::class, + IpGeolocationMiddleware::class, CoreAction\PixelAction::class, ], 'allowed_methods' => [RequestMethodInterface::METHOD_GET], @@ -105,6 +107,7 @@ return (static function (): array { 'path' => sprintf('/{shortCode}%s', $shortUrlRouteSuffix), 'middleware' => [ IpAddress::class, + IpGeolocationMiddleware::class, TrimTrailingSlashMiddleware::class, CoreAction\RedirectAction::class, ], diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index ad3452e4..4844e6d5 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -11,6 +11,7 @@ use Shlinkio\Shlink\Common\Doctrine\EntityRepositoryFactory; use Shlinkio\Shlink\Core\Config\Options\NotFoundRedirectOptions; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier; use Shlinkio\Shlink\Importer\ImportedLinksProcessorInterface; +use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdater; use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; use Symfony\Component\Lock; @@ -102,6 +103,8 @@ return [ EventDispatcher\PublishingUpdatesGenerator::class => ConfigAbstractFactory::class, + Geolocation\Middleware\IpGeolocationMiddleware::class => ConfigAbstractFactory::class, + Importer\ImportedLinksProcessor::class => ConfigAbstractFactory::class, Crawling\CrawlingHelper::class => ConfigAbstractFactory::class, @@ -237,6 +240,13 @@ return [ EventDispatcher\PublishingUpdatesGenerator::class => [ShortUrl\Transformer\ShortUrlDataTransformer::class], + Geolocation\Middleware\IpGeolocationMiddleware::class => [ + IpLocationResolverInterface::class, + DbUpdater::class, + 'Logger_Shlink', + Config\Options\TrackingOptions::class, + ], + Importer\ImportedLinksProcessor::class => [ 'em', ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class, diff --git a/module/Core/config/event_dispatcher.config.php b/module/Core/config/event_dispatcher.config.php index 2491d606..4e130fcf 100644 --- a/module/Core/config/event_dispatcher.config.php +++ b/module/Core/config/event_dispatcher.config.php @@ -15,23 +15,18 @@ use Shlinkio\Shlink\Core\Matomo\MatomoOptions; use Shlinkio\Shlink\Core\Visit\Geolocation\VisitLocator; use Shlinkio\Shlink\Core\Visit\Geolocation\VisitToLocationHelper; use Shlinkio\Shlink\EventDispatcher\Listener\EnabledListenerCheckerInterface; -use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdater; use Shlinkio\Shlink\IpGeolocation\GeoLite2\GeoLite2Options; -use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; use function Shlinkio\Shlink\Config\runningInRoadRunner; return (static function (): array { $regularEvents = [ - EventDispatcher\Event\UrlVisited::class => [ - EventDispatcher\LocateVisit::class, - ], EventDispatcher\Event\GeoLiteDbCreated::class => [ EventDispatcher\LocateUnlocatedVisits::class, ], ]; $asyncEvents = [ - EventDispatcher\Event\VisitLocated::class => [ + EventDispatcher\Event\UrlVisited::class => [ EventDispatcher\Mercure\NotifyVisitToMercure::class, EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class, EventDispatcher\RedisPubSub\NotifyVisitToRedis::class, @@ -46,9 +41,9 @@ return (static function (): array { // Send visits to matomo asynchronously if the runtime allows it if (runningInRoadRunner()) { - $asyncEvents[EventDispatcher\Event\VisitLocated::class][] = EventDispatcher\Matomo\SendVisitToMatomo::class; + $asyncEvents[EventDispatcher\Event\UrlVisited::class][] = EventDispatcher\Matomo\SendVisitToMatomo::class; } else { - $regularEvents[EventDispatcher\Event\VisitLocated::class] = [EventDispatcher\Matomo\SendVisitToMatomo::class]; + $regularEvents[EventDispatcher\Event\UrlVisited::class] = [EventDispatcher\Matomo\SendVisitToMatomo::class]; } return [ @@ -60,7 +55,6 @@ return (static function (): array { 'dependencies' => [ 'factories' => [ - EventDispatcher\LocateVisit::class => ConfigAbstractFactory::class, EventDispatcher\Matomo\SendVisitToMatomo::class => ConfigAbstractFactory::class, EventDispatcher\LocateUnlocatedVisits::class => ConfigAbstractFactory::class, EventDispatcher\Mercure\NotifyVisitToMercure::class => ConfigAbstractFactory::class, @@ -104,13 +98,6 @@ return (static function (): array { ], ConfigAbstractFactory::class => [ - EventDispatcher\LocateVisit::class => [ - IpLocationResolverInterface::class, - 'em', - 'Logger_Shlink', - DbUpdater::class, - EventDispatcherInterface::class, - ], EventDispatcher\LocateUnlocatedVisits::class => [VisitLocator::class, VisitToLocationHelper::class], EventDispatcher\Mercure\NotifyVisitToMercure::class => [ MercureHubPublishingHelper::class, diff --git a/module/Core/src/Action/AbstractTrackingAction.php b/module/Core/src/Action/AbstractTrackingAction.php index b7ddb69a..78eebc05 100644 --- a/module/Core/src/Action/AbstractTrackingAction.php +++ b/module/Core/src/Action/AbstractTrackingAction.php @@ -15,7 +15,6 @@ use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface; -use Shlinkio\Shlink\IpGeolocation\Model\Location; abstract class AbstractTrackingAction implements MiddlewareInterface, RequestMethodInterface { @@ -31,12 +30,9 @@ abstract class AbstractTrackingAction implements MiddlewareInterface, RequestMet try { $shortUrl = $this->urlResolver->resolveEnabledShortUrl($identifier); - $visit = $this->requestTracker->trackIfApplicable($shortUrl, $request); + $this->requestTracker->trackIfApplicable($shortUrl, $request); - return $this->createSuccessResp( - $shortUrl, - $request->withAttribute(Location::class, $visit?->getVisitLocation()), - ); + return $this->createSuccessResp($shortUrl, $request); } catch (ShortUrlNotFoundException) { return $this->createErrorResp($request, $handler); } diff --git a/module/Core/src/EventDispatcher/Async/AbstractNotifyVisitListener.php b/module/Core/src/EventDispatcher/Async/AbstractNotifyVisitListener.php index 3ec9417c..e871588f 100644 --- a/module/Core/src/EventDispatcher/Async/AbstractNotifyVisitListener.php +++ b/module/Core/src/EventDispatcher/Async/AbstractNotifyVisitListener.php @@ -8,7 +8,7 @@ use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; use Shlinkio\Shlink\Common\UpdatePublishing\PublishingHelperInterface; use Shlinkio\Shlink\Common\UpdatePublishing\Update; -use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated; +use Shlinkio\Shlink\Core\EventDispatcher\Event\UrlVisited; use Shlinkio\Shlink\Core\EventDispatcher\PublishingUpdatesGeneratorInterface; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Throwable; @@ -25,7 +25,7 @@ abstract class AbstractNotifyVisitListener extends AbstractAsyncListener ) { } - public function __invoke(VisitLocated $visitLocated): void + public function __invoke(UrlVisited $visitLocated): void { if (! $this->isEnabled()) { return; diff --git a/module/Core/src/EventDispatcher/Event/AbstractVisitEvent.php b/module/Core/src/EventDispatcher/Event/AbstractVisitEvent.php deleted file mode 100644 index c1fa440a..00000000 --- a/module/Core/src/EventDispatcher/Event/AbstractVisitEvent.php +++ /dev/null @@ -1,27 +0,0 @@ - $this->visitId, 'originalIpAddress' => $this->originalIpAddress]; - } - - public static function fromPayload(array $payload): self - { - return new static($payload['visitId'] ?? '', $payload['originalIpAddress'] ?? null); - } -} diff --git a/module/Core/src/EventDispatcher/Event/ShortUrlCreated.php b/module/Core/src/EventDispatcher/Event/ShortUrlCreated.php index b6ab1a0c..4055935f 100644 --- a/module/Core/src/EventDispatcher/Event/ShortUrlCreated.php +++ b/module/Core/src/EventDispatcher/Event/ShortUrlCreated.php @@ -7,9 +7,9 @@ namespace Shlinkio\Shlink\Core\EventDispatcher\Event; use JsonSerializable; use Shlinkio\Shlink\EventDispatcher\Util\JsonUnserializable; -final class ShortUrlCreated implements JsonSerializable, JsonUnserializable +final readonly class ShortUrlCreated implements JsonSerializable, JsonUnserializable { - public function __construct(public readonly string $shortUrlId) + public function __construct(public string $shortUrlId) { } diff --git a/module/Core/src/EventDispatcher/Event/UrlVisited.php b/module/Core/src/EventDispatcher/Event/UrlVisited.php index d1158a4e..0d25b1a1 100644 --- a/module/Core/src/EventDispatcher/Event/UrlVisited.php +++ b/module/Core/src/EventDispatcher/Event/UrlVisited.php @@ -4,6 +4,24 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\EventDispatcher\Event; -final class UrlVisited extends AbstractVisitEvent +use JsonSerializable; +use Shlinkio\Shlink\EventDispatcher\Util\JsonUnserializable; + +final readonly class UrlVisited implements JsonSerializable, JsonUnserializable { + final public function __construct( + public string $visitId, + public string|null $originalIpAddress = null, + ) { + } + + public function jsonSerialize(): array + { + return ['visitId' => $this->visitId, 'originalIpAddress' => $this->originalIpAddress]; + } + + public static function fromPayload(array $payload): self + { + return new self($payload['visitId'] ?? '', $payload['originalIpAddress'] ?? null); + } } diff --git a/module/Core/src/EventDispatcher/Event/VisitLocated.php b/module/Core/src/EventDispatcher/Event/VisitLocated.php deleted file mode 100644 index 99b7a05e..00000000 --- a/module/Core/src/EventDispatcher/Event/VisitLocated.php +++ /dev/null @@ -1,9 +0,0 @@ -visitId; - - /** @var Visit|null $visit */ - $visit = $this->em->find(Visit::class, $visitId); - if ($visit === null) { - $this->logger->warning('Tried to locate visit with id "{visitId}", but it does not exist.', [ - 'visitId' => $visitId, - ]); - return; - } - - $this->locateVisit($visitId, $shortUrlVisited->originalIpAddress, $visit); - $this->eventDispatcher->dispatch(new VisitLocated($visitId, $shortUrlVisited->originalIpAddress)); - } - - private function locateVisit(string $visitId, string|null $originalIpAddress, Visit $visit): void - { - if (! $this->dbUpdater->databaseFileExists()) { - $this->logger->warning('Tried to locate visit with id "{visitId}", but a GeoLite2 db was not found.', [ - 'visitId' => $visitId, - ]); - return; - } - - $isLocatable = $originalIpAddress !== null || $visit->isLocatable(); - $addr = $originalIpAddress ?? $visit->remoteAddr ?? ''; - - try { - $location = $isLocatable ? $this->ipLocationResolver->resolveIpLocation($addr) : Location::emptyInstance(); - - $visit->locate(VisitLocation::fromGeolocation($location)); - $this->em->flush(); - } catch (WrongIpException $e) { - $this->logger->warning( - 'Tried to locate visit with id "{visitId}", but its address seems to be wrong. {e}', - ['e' => $e, 'visitId' => $visitId], - ); - } catch (Throwable $e) { - $this->logger->error( - 'An unexpected error occurred while trying to locate visit with id "{visitId}". {e}', - ['e' => $e, 'visitId' => $visitId], - ); - } - } -} diff --git a/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php b/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php index 5a85aed4..c7b5bd3c 100644 --- a/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php +++ b/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php @@ -6,7 +6,7 @@ namespace Shlinkio\Shlink\Core\EventDispatcher\Matomo; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; -use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated; +use Shlinkio\Shlink\Core\EventDispatcher\Event\UrlVisited; use Shlinkio\Shlink\Core\Matomo\MatomoOptions; use Shlinkio\Shlink\Core\Matomo\MatomoVisitSenderInterface; use Shlinkio\Shlink\Core\Visit\Entity\Visit; @@ -22,7 +22,7 @@ readonly class SendVisitToMatomo ) { } - public function __invoke(VisitLocated $visitLocated): void + public function __invoke(UrlVisited $visitLocated): void { if (! $this->matomoOptions->enabled) { return; diff --git a/module/Core/src/Geolocation/Middleware/IpGeolocationMiddleware.php b/module/Core/src/Geolocation/Middleware/IpGeolocationMiddleware.php new file mode 100644 index 00000000..4e2e533b --- /dev/null +++ b/module/Core/src/Geolocation/Middleware/IpGeolocationMiddleware.php @@ -0,0 +1,58 @@ +trackingOptions->isGeolocationRelevant()) { + return $handler->handle($request); + } + + if (! $this->dbUpdater->databaseFileExists()) { + $this->logger->warning('Tried to geolocate IP address, but a GeoLite2 db was not found.'); + return $handler->handle($request); + } + + $location = $this->geolocateIpAddress(ipAddressFromRequest($request)); + return $handler->handle($request->withAttribute(Location::class, $location)); + } + + private function geolocateIpAddress(string|null $ipAddress): Location + { + try { + return $ipAddress === null ? Location::empty() : $this->ipLocationResolver->resolveIpLocation($ipAddress); + } catch (WrongIpException $e) { + $this->logger->warning('Tried to locate IP address, but it seems to be wrong. {e}', ['e' => $e]); + return Location::empty(); + } catch (Throwable $e) { + $this->logger->error('An unexpected error occurred while trying to locate IP address. {e}', ['e' => $e]); + return Location::empty(); + } + } +} diff --git a/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php b/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php index 2b4ac7cc..4a02f6e9 100644 --- a/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php +++ b/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php @@ -17,7 +17,6 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface; use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface; -use Shlinkio\Shlink\IpGeolocation\Model\Location; use function array_slice; use function count; @@ -74,13 +73,9 @@ readonly class ExtraPathRedirectMiddleware implements MiddlewareInterface try { $shortUrl = $this->resolver->resolveEnabledShortUrl($identifier); - $visit = $this->requestTracker->trackIfApplicable($shortUrl, $request); + $this->requestTracker->trackIfApplicable($shortUrl, $request); - $longUrl = $this->redirectionBuilder->buildShortUrlRedirect( - $shortUrl, - $request->withAttribute(Location::class, $visit?->getVisitLocation()), - $extraPath, - ); + $longUrl = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, $request, $extraPath); return $this->redirectResponseHelper->buildRedirectResponse($longUrl); } catch (ShortUrlNotFoundException) { if ($extraPath === null || ! $this->urlShortenerOptions->multiSegmentSlugsEnabled) { diff --git a/module/Core/src/Visit/Entity/Visit.php b/module/Core/src/Visit/Entity/Visit.php index 4e3c48a8..e26d5f80 100644 --- a/module/Core/src/Visit/Entity/Visit.php +++ b/module/Core/src/Visit/Entity/Visit.php @@ -59,6 +59,7 @@ class Visit extends AbstractEntity implements JsonSerializable Visitor $visitor, bool $anonymize, ): self { + $geolocation = $visitor->geolocation; return new self( shortUrl: $shortUrl, type: $type, @@ -67,6 +68,7 @@ class Visit extends AbstractEntity implements JsonSerializable potentialBot: $visitor->potentialBot, remoteAddr: self::processAddress($visitor->remoteAddress, $anonymize), visitedUrl: $visitor->visitedUrl, + visitLocation: $geolocation !== null ? VisitLocation::fromGeolocation($geolocation) : null, ); } diff --git a/module/Core/test/EventDispatcher/LocateVisitTest.php b/module/Core/test/EventDispatcher/LocateVisitTest.php deleted file mode 100644 index b88bf470..00000000 --- a/module/Core/test/EventDispatcher/LocateVisitTest.php +++ /dev/null @@ -1,203 +0,0 @@ -ipLocationResolver = $this->createMock(IpLocationResolverInterface::class); - $this->em = $this->createMock(EntityManagerInterface::class); - $this->logger = $this->createMock(LoggerInterface::class); - $this->eventDispatcher = $this->createMock(EventDispatcherInterface::class); - $this->dbUpdater = $this->createMock(DbUpdaterInterface::class); - - $this->locateVisit = new LocateVisit( - $this->ipLocationResolver, - $this->em, - $this->logger, - $this->dbUpdater, - $this->eventDispatcher, - ); - } - - #[Test] - public function invalidVisitLogsWarning(): void - { - $event = new UrlVisited('123'); - $this->em->expects($this->once())->method('find')->with(Visit::class, '123')->willReturn(null); - $this->em->expects($this->never())->method('flush'); - $this->logger->expects($this->once())->method('warning')->with( - 'Tried to locate visit with id "{visitId}", but it does not exist.', - ['visitId' => 123], - ); - $this->eventDispatcher->expects($this->never())->method('dispatch')->with(new VisitLocated('123')); - $this->ipLocationResolver->expects($this->never())->method('resolveIpLocation'); - - ($this->locateVisit)($event); - } - - #[Test] - public function nonExistingGeoLiteDbLogsWarning(): void - { - $event = new UrlVisited('123'); - $this->em->expects($this->once())->method('find')->with(Visit::class, '123')->willReturn( - Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::fromParams(remoteAddress: '1.2.3.4')), - ); - $this->em->expects($this->never())->method('flush'); - $this->dbUpdater->expects($this->once())->method('databaseFileExists')->withAnyParameters()->willReturn(false); - $this->logger->expects($this->once())->method('warning')->with( - 'Tried to locate visit with id "{visitId}", but a GeoLite2 db was not found.', - ['visitId' => 123], - ); - $this->eventDispatcher->expects($this->once())->method('dispatch')->with(new VisitLocated('123')); - $this->ipLocationResolver->expects($this->never())->method('resolveIpLocation'); - - ($this->locateVisit)($event); - } - - #[Test] - public function invalidAddressLogsWarning(): void - { - $event = new UrlVisited('123'); - $this->em->expects($this->once())->method('find')->with(Visit::class, '123')->willReturn( - Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::fromParams(remoteAddress: '1.2.3.4')), - ); - $this->em->expects($this->never())->method('flush'); - $this->dbUpdater->expects($this->once())->method('databaseFileExists')->withAnyParameters()->willReturn(true); - $this->ipLocationResolver->expects( - $this->once(), - )->method('resolveIpLocation')->withAnyParameters()->willThrowException(WrongIpException::fromIpAddress('')); - $this->logger->expects($this->once())->method('warning')->with( - 'Tried to locate visit with id "{visitId}", but its address seems to be wrong. {e}', - $this->isType('array'), - ); - $this->eventDispatcher->expects($this->once())->method('dispatch')->with(new VisitLocated('123')); - - ($this->locateVisit)($event); - } - - #[Test] - public function unhandledExceptionLogsError(): void - { - $event = new UrlVisited('123'); - $this->em->expects($this->once())->method('find')->with(Visit::class, '123')->willReturn( - Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::fromParams(remoteAddress: '1.2.3.4')), - ); - $this->em->expects($this->never())->method('flush'); - $this->dbUpdater->expects($this->once())->method('databaseFileExists')->withAnyParameters()->willReturn(true); - $this->ipLocationResolver->expects( - $this->once(), - )->method('resolveIpLocation')->withAnyParameters()->willThrowException(new OutOfRangeException()); - $this->logger->expects($this->once())->method('error')->with( - 'An unexpected error occurred while trying to locate visit with id "{visitId}". {e}', - $this->isType('array'), - ); - $this->eventDispatcher->expects($this->once())->method('dispatch')->with(new VisitLocated('123')); - - ($this->locateVisit)($event); - } - - #[Test, DataProvider('provideNonLocatableVisits')] - public function nonLocatableVisitsResolveToEmptyLocations(Visit $visit): void - { - $event = new UrlVisited('123'); - $this->em->expects($this->once())->method('find')->with(Visit::class, '123')->willReturn($visit); - $this->em->expects($this->once())->method('flush'); - $this->dbUpdater->expects($this->once())->method('databaseFileExists')->withAnyParameters()->willReturn(true); - $this->ipLocationResolver->expects($this->never())->method('resolveIpLocation'); - - $this->eventDispatcher->expects($this->once())->method('dispatch')->with(new VisitLocated('123')); - $this->logger->expects($this->never())->method('warning'); - - ($this->locateVisit)($event); - - self::assertEquals($visit->getVisitLocation(), VisitLocation::fromGeolocation(Location::emptyInstance())); - } - - public static function provideNonLocatableVisits(): iterable - { - $shortUrl = ShortUrl::createFake(); - - yield 'null IP' => [Visit::forValidShortUrl($shortUrl, Visitor::empty())]; - yield 'empty IP' => [Visit::forValidShortUrl($shortUrl, Visitor::fromParams(remoteAddress: ''))]; - yield 'localhost' => [ - Visit::forValidShortUrl($shortUrl, Visitor::fromParams(remoteAddress: IpAddress::LOCALHOST)), - ]; - } - - #[Test, DataProvider('provideIpAddresses')] - public function locatableVisitsResolveToLocation(Visit $visit, string|null $originalIpAddress): void - { - $ipAddr = $originalIpAddress ?? $visit->remoteAddr; - $location = new Location('', '', '', '', 0.0, 0.0, ''); - $event = new UrlVisited('123', $originalIpAddress); - - $this->em->expects($this->once())->method('find')->with(Visit::class, '123')->willReturn($visit); - $this->em->expects($this->once())->method('flush'); - $this->dbUpdater->expects($this->once())->method('databaseFileExists')->withAnyParameters()->willReturn(true); - $this->ipLocationResolver->expects($this->once())->method('resolveIpLocation')->with($ipAddr)->willReturn( - $location, - ); - - $this->eventDispatcher->expects($this->once())->method('dispatch')->with( - new VisitLocated('123', $originalIpAddress), - ); - $this->logger->expects($this->never())->method('warning'); - - ($this->locateVisit)($event); - - self::assertEquals($visit->getVisitLocation(), VisitLocation::fromGeolocation($location)); - } - - public static function provideIpAddresses(): iterable - { - yield 'no original IP address' => [ - Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::fromParams(remoteAddress: '1.2.3.4')), - null, - ]; - yield 'original IP address' => [ - Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::fromParams(remoteAddress: '1.2.3.4')), - '1.2.3.4', - ]; - yield 'base url' => [Visit::forBasePath(Visitor::fromParams(remoteAddress: '1.2.3.4')), '1.2.3.4']; - yield 'invalid short url' => [ - Visit::forInvalidShortUrl(Visitor::fromParams(remoteAddress: '1.2.3.4')), - '1.2.3.4', - ]; - yield 'regular not found' => [ - Visit::forRegularNotFound(Visitor::fromParams(remoteAddress: '1.2.3.4')), - '1.2.3.4', - ]; - } -} diff --git a/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php b/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php index 725980a1..8a4c1b7d 100644 --- a/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php +++ b/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php @@ -11,7 +11,7 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; -use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated; +use Shlinkio\Shlink\Core\EventDispatcher\Event\UrlVisited; use Shlinkio\Shlink\Core\EventDispatcher\Matomo\SendVisitToMatomo; use Shlinkio\Shlink\Core\Matomo\MatomoOptions; use Shlinkio\Shlink\Core\Matomo\MatomoVisitSenderInterface; @@ -39,7 +39,7 @@ class SendVisitToMatomoTest extends TestCase $this->logger->expects($this->never())->method('error'); $this->logger->expects($this->never())->method('warning'); - ($this->listener(enabled: false))(new VisitLocated('123')); + ($this->listener(enabled: false))(new UrlVisited('123')); } #[Test] @@ -53,7 +53,7 @@ class SendVisitToMatomoTest extends TestCase ['visitId' => '123'], ); - ($this->listener())(new VisitLocated('123')); + ($this->listener())(new UrlVisited('123')); } #[Test, DataProvider('provideOriginalIpAddress')] @@ -67,7 +67,7 @@ class SendVisitToMatomoTest extends TestCase $this->logger->expects($this->never())->method('error'); $this->logger->expects($this->never())->method('warning'); - ($this->listener())(new VisitLocated($visitId, $originalIpAddress)); + ($this->listener())(new UrlVisited($visitId, $originalIpAddress)); } public static function provideOriginalIpAddress(): iterable @@ -92,7 +92,7 @@ class SendVisitToMatomoTest extends TestCase ['e' => $e], ); - ($this->listener())(new VisitLocated($visitId)); + ($this->listener())(new UrlVisited($visitId)); } private function listener(bool $enabled = true): SendVisitToMatomo diff --git a/module/Core/test/EventDispatcher/Mercure/NotifyVisitToMercureTest.php b/module/Core/test/EventDispatcher/Mercure/NotifyVisitToMercureTest.php index 1e3dfb96..91569c9b 100644 --- a/module/Core/test/EventDispatcher/Mercure/NotifyVisitToMercureTest.php +++ b/module/Core/test/EventDispatcher/Mercure/NotifyVisitToMercureTest.php @@ -13,7 +13,7 @@ use Psr\Log\LoggerInterface; use RuntimeException; use Shlinkio\Shlink\Common\UpdatePublishing\PublishingHelperInterface; use Shlinkio\Shlink\Common\UpdatePublishing\Update; -use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated; +use Shlinkio\Shlink\Core\EventDispatcher\Event\UrlVisited; use Shlinkio\Shlink\Core\EventDispatcher\Mercure\NotifyVisitToMercure; use Shlinkio\Shlink\Core\EventDispatcher\PublishingUpdatesGeneratorInterface; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; @@ -54,7 +54,7 @@ class NotifyVisitToMercureTest extends TestCase $this->updatesGenerator->expects($this->never())->method('newVisitUpdate'); $this->helper->expects($this->never())->method('publishUpdate'); - ($this->listener)(new VisitLocated($visitId)); + ($this->listener)(new UrlVisited($visitId)); } #[Test] @@ -74,7 +74,7 @@ class NotifyVisitToMercureTest extends TestCase $this->updatesGenerator->expects($this->once())->method('newVisitUpdate')->with($visit)->willReturn($update); $this->helper->expects($this->exactly(2))->method('publishUpdate')->with($update); - ($this->listener)(new VisitLocated($visitId)); + ($this->listener)(new UrlVisited($visitId)); } #[Test] @@ -98,7 +98,7 @@ class NotifyVisitToMercureTest extends TestCase $this->updatesGenerator->expects($this->once())->method('newVisitUpdate')->with($visit)->willReturn($update); $this->helper->expects($this->once())->method('publishUpdate')->with($update)->willThrowException($e); - ($this->listener)(new VisitLocated($visitId)); + ($this->listener)(new UrlVisited($visitId)); } #[Test, DataProvider('provideOrphanVisits')] @@ -117,7 +117,7 @@ class NotifyVisitToMercureTest extends TestCase $this->updatesGenerator->expects($this->never())->method('newVisitUpdate'); $this->helper->expects($this->once())->method('publishUpdate')->with($update); - ($this->listener)(new VisitLocated($visitId)); + ($this->listener)(new UrlVisited($visitId)); } public static function provideOrphanVisits(): iterable diff --git a/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php b/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php index 26785897..a3cebaa7 100644 --- a/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php +++ b/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php @@ -17,7 +17,7 @@ use RuntimeException; use Shlinkio\Shlink\Common\UpdatePublishing\PublishingHelperInterface; use Shlinkio\Shlink\Common\UpdatePublishing\Update; use Shlinkio\Shlink\Core\Config\Options\RabbitMqOptions; -use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated; +use Shlinkio\Shlink\Core\EventDispatcher\Event\UrlVisited; use Shlinkio\Shlink\Core\EventDispatcher\PublishingUpdatesGeneratorInterface; use Shlinkio\Shlink\Core\EventDispatcher\RabbitMq\NotifyVisitToRabbitMq; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; @@ -52,7 +52,7 @@ class NotifyVisitToRabbitMqTest extends TestCase $this->logger->expects($this->never())->method('warning'); $this->logger->expects($this->never())->method('debug'); - ($this->listener(new RabbitMqOptions(enabled: false)))(new VisitLocated('123')); + ($this->listener(new RabbitMqOptions(enabled: false)))(new UrlVisited('123')); } #[Test] @@ -67,7 +67,7 @@ class NotifyVisitToRabbitMqTest extends TestCase $this->logger->expects($this->never())->method('debug'); $this->helper->expects($this->never())->method('publishUpdate'); - ($this->listener())(new VisitLocated($visitId)); + ($this->listener())(new UrlVisited($visitId)); } #[Test, DataProvider('provideVisits')] @@ -85,7 +85,7 @@ class NotifyVisitToRabbitMqTest extends TestCase ); $this->logger->expects($this->never())->method('debug'); - ($this->listener())(new VisitLocated($visitId)); + ($this->listener())(new UrlVisited($visitId)); } public static function provideVisits(): iterable @@ -121,7 +121,7 @@ class NotifyVisitToRabbitMqTest extends TestCase ['e' => $e, 'name' => 'RabbitMQ'], ); - ($this->listener())(new VisitLocated($visitId)); + ($this->listener())(new UrlVisited($visitId)); } public static function provideExceptions(): iterable @@ -142,7 +142,7 @@ class NotifyVisitToRabbitMqTest extends TestCase $setup($this->updatesGenerator); $expect($this->helper, $this->updatesGenerator); - ($this->listener())(new VisitLocated($visitId)); + ($this->listener())(new UrlVisited($visitId)); } public static function providePayloads(): iterable diff --git a/module/Core/test/EventDispatcher/RedisPubSub/NotifyVisitToRedisTest.php b/module/Core/test/EventDispatcher/RedisPubSub/NotifyVisitToRedisTest.php index 20cd786a..7cbf68b6 100644 --- a/module/Core/test/EventDispatcher/RedisPubSub/NotifyVisitToRedisTest.php +++ b/module/Core/test/EventDispatcher/RedisPubSub/NotifyVisitToRedisTest.php @@ -15,7 +15,7 @@ use Psr\Log\LoggerInterface; use RuntimeException; use Shlinkio\Shlink\Common\UpdatePublishing\PublishingHelperInterface; use Shlinkio\Shlink\Common\UpdatePublishing\Update; -use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated; +use Shlinkio\Shlink\Core\EventDispatcher\Event\UrlVisited; use Shlinkio\Shlink\Core\EventDispatcher\PublishingUpdatesGeneratorInterface; use Shlinkio\Shlink\Core\EventDispatcher\RedisPubSub\NotifyVisitToRedis; use Shlinkio\Shlink\Core\Visit\Entity\Visit; @@ -45,7 +45,7 @@ class NotifyVisitToRedisTest extends TestCase $this->logger->expects($this->never())->method('warning'); $this->logger->expects($this->never())->method('debug'); - $this->createListener(false)(new VisitLocated('123')); + $this->createListener(false)(new UrlVisited('123')); } #[Test, DataProvider('provideExceptions')] @@ -64,7 +64,7 @@ class NotifyVisitToRedisTest extends TestCase ['e' => $e, 'name' => 'Redis pub/sub'], ); - $this->createListener()(new VisitLocated($visitId)); + $this->createListener()(new UrlVisited($visitId)); } public static function provideExceptions(): iterable diff --git a/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php b/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php index 2e35c1a4..85168020 100644 --- a/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php +++ b/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php @@ -9,7 +9,6 @@ use Laminas\Diactoros\ServerRequestFactory; use Laminas\Diactoros\Uri; use Mezzio\Router\Route; use Mezzio\Router\RouteResult; -use PHPUnit\Framework\Assert; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; @@ -27,7 +26,6 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface; use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface; -use Shlinkio\Shlink\IpGeolocation\Model\Location; use function Laminas\Stratigility\middleware; use function str_starts_with; @@ -155,10 +153,7 @@ class ExtraPathRedirectMiddlewareTest extends TestCase ); $this->redirectionBuilder->expects($this->once())->method('buildShortUrlRedirect')->with( $shortUrl, - $this->callback(function (ServerRequestInterface $req) { - Assert::assertArrayHasKey(Location::class, $req->getAttributes()); - return true; - }), + $this->isInstanceOf(ServerRequestInterface::class), $expectedExtraPath, )->willReturn('the_built_long_url'); $this->redirectResponseHelper->expects($this->once())->method('buildRedirectResponse')->with( From 6aaea2ac26e13fcf62983cf1292f5ea33f6411cf Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 15 Nov 2024 09:00:59 +0100 Subject: [PATCH 56/80] Simplify logic in RedirectRule when checking geolocation conditions --- module/Core/functions/functions.php | 7 +++++- .../RedirectRule/Entity/RedirectCondition.php | 16 +++++------- .../Entity/RedirectConditionTest.php | 25 ++----------------- 3 files changed, 14 insertions(+), 34 deletions(-) diff --git a/module/Core/functions/functions.php b/module/Core/functions/functions.php index 1d989c75..6ccc42e2 100644 --- a/module/Core/functions/functions.php +++ b/module/Core/functions/functions.php @@ -293,5 +293,10 @@ function ipAddressFromRequest(ServerRequestInterface $request): string|null function geolocationFromRequest(ServerRequestInterface $request): Location|null { - return $request->getAttribute(Location::class); + $geolocation = $request->getAttribute(Location::class); + if ($geolocation !== null && ! $geolocation instanceof Location) { + // TODO Throw exception + } + + return $geolocation; } diff --git a/module/Core/src/RedirectRule/Entity/RedirectCondition.php b/module/Core/src/RedirectRule/Entity/RedirectCondition.php index 9468d582..cf1e134b 100644 --- a/module/Core/src/RedirectRule/Entity/RedirectCondition.php +++ b/module/Core/src/RedirectRule/Entity/RedirectCondition.php @@ -9,11 +9,10 @@ use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType; use Shlinkio\Shlink\Core\RedirectRule\Model\Validation\RedirectRulesInputFilter; use Shlinkio\Shlink\Core\Util\IpAddressUtils; -use Shlinkio\Shlink\Core\Visit\Entity\VisitLocation; -use Shlinkio\Shlink\IpGeolocation\Model\Location; use function Shlinkio\Shlink\Core\acceptLanguageToLocales; use function Shlinkio\Shlink\Core\ArrayUtils\some; +use function Shlinkio\Shlink\Core\geolocationFromRequest; use function Shlinkio\Shlink\Core\ipAddressFromRequest; use function Shlinkio\Shlink\Core\normalizeLocale; use function Shlinkio\Shlink\Core\splitLocale; @@ -134,9 +133,8 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable private function matchesGeolocationCountryCode(ServerRequestInterface $request): bool { - $geolocation = $request->getAttribute(Location::class); - // TODO We should eventually rely on `Location` type only - if (! ($geolocation instanceof Location) && ! ($geolocation instanceof VisitLocation)) { + $geolocation = geolocationFromRequest($request); + if ($geolocation === null) { return false; } @@ -145,14 +143,12 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable private function matchesGeolocationCityName(ServerRequestInterface $request): bool { - $geolocation = $request->getAttribute(Location::class); - // TODO We should eventually rely on `Location` type only - if (! ($geolocation instanceof Location) && ! ($geolocation instanceof VisitLocation)) { + $geolocation = geolocationFromRequest($request); + if ($geolocation === null) { return false; } - $cityName = $geolocation instanceof Location ? $geolocation->city : $geolocation->cityName; - return strcasecmp($cityName, $this->matchValue) === 0; + return strcasecmp($geolocation->city, $this->matchValue) === 0; } public function jsonSerialize(): array diff --git a/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php b/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php index d22b632d..2ae5df18 100644 --- a/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php +++ b/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php @@ -10,7 +10,6 @@ use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory; use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\RedirectRule\Entity\RedirectCondition; -use Shlinkio\Shlink\Core\Visit\Entity\VisitLocation; use Shlinkio\Shlink\IpGeolocation\Model\Location; use const ShlinkioTest\Shlink\ANDROID_USER_AGENT; @@ -99,7 +98,7 @@ class RedirectConditionTest extends TestCase #[Test, DataProvider('provideVisitsWithCountry')] public function matchesGeolocationCountryCode( - Location|VisitLocation|null $location, + Location|null $location, string $countryCodeToMatch, bool $expected, ): void { @@ -114,21 +113,11 @@ class RedirectConditionTest extends TestCase yield 'non-matching location' => [new Location(countryCode: 'ES'), 'US', false]; yield 'matching location' => [new Location(countryCode: 'US'), 'US', true]; yield 'matching case-insensitive' => [new Location(countryCode: 'US'), 'us', true]; - yield 'matching visit location' => [ - VisitLocation::fromGeolocation(new Location(countryCode: 'US')), - 'US', - true, - ]; - yield 'matching visit case-insensitive' => [ - VisitLocation::fromGeolocation(new Location(countryCode: 'es')), - 'ES', - true, - ]; } #[Test, DataProvider('provideVisitsWithCity')] public function matchesGeolocationCityName( - Location|VisitLocation|null $location, + Location|null $location, string $cityNameToMatch, bool $expected, ): void { @@ -143,15 +132,5 @@ class RedirectConditionTest extends TestCase yield 'non-matching location' => [new Location(city: 'Los Angeles'), 'New York', false]; yield 'matching location' => [new Location(city: 'Madrid'), 'Madrid', true]; yield 'matching case-insensitive' => [new Location(city: 'Los Angeles'), 'los angeles', true]; - yield 'matching visit location' => [ - VisitLocation::fromGeolocation(new Location(city: 'New York')), - 'New York', - true, - ]; - yield 'matching visit case-insensitive' => [ - VisitLocation::fromGeolocation(new Location(city: 'barcelona')), - 'Barcelona', - true, - ]; } } From 42ff0d5b691d294558b2a4dce967ba9167915548 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 15 Nov 2024 10:17:09 +0100 Subject: [PATCH 57/80] Create IpGeolocationMiddlewareTest --- .../IpGeolocationMiddlewareTest.php | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 module/Core/test/Geolocation/Middleware/IpGeolocationMiddlewareTest.php diff --git a/module/Core/test/Geolocation/Middleware/IpGeolocationMiddlewareTest.php b/module/Core/test/Geolocation/Middleware/IpGeolocationMiddlewareTest.php new file mode 100644 index 00000000..f203fb85 --- /dev/null +++ b/module/Core/test/Geolocation/Middleware/IpGeolocationMiddlewareTest.php @@ -0,0 +1,172 @@ +ipLocationResolver = $this->createMock(IpLocationResolverInterface::class); + $this->dbUpdater = $this->createMock(DbUpdaterInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->handler = $this->createMock(RequestHandlerInterface::class); + } + + #[Test] + public function geolocationIsSkippedIfTrackingIsDisabled(): void + { + $this->dbUpdater->expects($this->never())->method('databaseFileExists'); + $this->ipLocationResolver->expects($this->never())->method('resolveIpLocation'); + $this->logger->expects($this->never())->method('warning'); + + $request = ServerRequestFactory::fromGlobals(); + $this->handler->expects($this->once())->method('handle')->with($request); + + $this->middleware(disableTracking: true)->process($request, $this->handler); + } + + #[Test] + public function warningIsLoggedIfGeoLiteDbDoesNotExist(): void + { + $this->ipLocationResolver->expects($this->never())->method('resolveIpLocation'); + $this->dbUpdater->expects($this->once())->method('databaseFileExists')->willReturn(false); + $this->logger->expects($this->once())->method('warning')->with( + 'Tried to geolocate IP address, but a GeoLite2 db was not found.', + ); + + $request = ServerRequestFactory::fromGlobals(); + $this->handler->expects($this->once())->method('handle')->with($request); + + $this->middleware()->process($request, $this->handler); + } + + #[Test] + public function emptyLocationIsReturnedIfIpAddressDoesNotExistInRequest(): void + { + $this->dbUpdater->expects($this->once())->method('databaseFileExists')->willReturn(true); + $this->ipLocationResolver->expects($this->never())->method('resolveIpLocation'); + $this->logger->expects($this->never())->method('warning'); + + $request = ServerRequestFactory::fromGlobals(); + $this->handler->expects($this->once())->method('handle')->with($this->callback( + function (ServerRequestInterface $req): bool { + $location = $req->getAttribute(Location::class); + if (! $location instanceof Location) { + return false; + } + + Assert::assertEmpty($location->countryCode); + return true; + }, + )); + + $this->middleware()->process($request, $this->handler); + } + + #[Test] + public function locationIsResolvedFromIpAddress(): void + { + $this->dbUpdater->expects($this->once())->method('databaseFileExists')->willReturn(true); + $this->ipLocationResolver->expects($this->once())->method('resolveIpLocation')->with('1.2.3.4')->willReturn( + new Location(countryCode: 'ES'), + ); + $this->logger->expects($this->never())->method('warning'); + + $request = ServerRequestFactory::fromGlobals()->withAttribute( + IpAddressMiddlewareFactory::REQUEST_ATTR, + '1.2.3.4', + ); + $this->handler->expects($this->once())->method('handle')->with($this->callback( + function (ServerRequestInterface $req): bool { + $location = $req->getAttribute(Location::class); + if (! $location instanceof Location) { + return false; + } + + Assert::assertEquals('ES', $location->countryCode); + return true; + }, + )); + + $this->middleware()->process($request, $this->handler); + } + + #[Test] + #[TestWith([ + new WrongIpException(), + 'warning', + 'Tried to locate IP address, but it seems to be wrong. {e}', + ])] + #[TestWith([ + new RuntimeException('Unknown'), + 'error', + 'An unexpected error occurred while trying to locate IP address. {e}', + ])] + public function warningIsPrintedIfAnErrorOccurs( + Throwable $exception, + string $loggerMethod, + string $expectedLoggedMessage, + ): void { + $this->dbUpdater->expects($this->once())->method('databaseFileExists')->willReturn(true); + $this->ipLocationResolver + ->expects($this->once()) + ->method('resolveIpLocation') + ->with('1.2.3.4') + ->willThrowException($exception); + $this->logger->expects($this->once())->method($loggerMethod)->with($expectedLoggedMessage, ['e' => $exception]); + + $request = ServerRequestFactory::fromGlobals()->withAttribute( + IpAddressMiddlewareFactory::REQUEST_ATTR, + '1.2.3.4', + ); + $this->handler->expects($this->once())->method('handle')->with($this->callback( + function (ServerRequestInterface $req): bool { + $location = $req->getAttribute(Location::class); + if (! $location instanceof Location) { + return false; + } + + Assert::assertEmpty($location->countryCode); + return true; + }, + )); + + $this->middleware()->process($request, $this->handler); + } + + private function middleware(bool $disableTracking = false): IpGeolocationMiddleware + { + return new IpGeolocationMiddleware( + $this->ipLocationResolver, + $this->dbUpdater, + $this->logger, + new TrackingOptions(disableTracking: $disableTracking), + ); + } +} From a9ae4a24d0107ea2d3a8065bed0cf65ddf4fe941 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 17 Nov 2024 10:15:16 +0100 Subject: [PATCH 58/80] Do not allow pipelines to continue on error --- .github/workflows/ci-db-tests.yml | 1 - .github/workflows/ci-tests.yml | 1 - 2 files changed, 2 deletions(-) diff --git a/.github/workflows/ci-db-tests.yml b/.github/workflows/ci-db-tests.yml index 33bf8f88..010c635f 100644 --- a/.github/workflows/ci-db-tests.yml +++ b/.github/workflows/ci-db-tests.yml @@ -14,7 +14,6 @@ jobs: strategy: matrix: php-version: ['8.2', '8.3', '8.4'] - continue-on-error: ${{ matrix.php-version == '8.4' }} env: LC_ALL: C steps: diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 70fe8049..c26aaaca 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -14,7 +14,6 @@ jobs: strategy: matrix: php-version: ['8.2', '8.3', '8.4'] - continue-on-error: ${{ matrix.php-version == '8.4' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # rr get-binary picks this env automatically steps: From b11d5c6864718bc58e2bc3583fc2f725b8fd7c85 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 18 Nov 2024 08:50:20 +0100 Subject: [PATCH 59/80] Do not ignore platform reqs when using PHP 8.4 --- .github/actions/ci-setup/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/ci-setup/action.yml b/.github/actions/ci-setup/action.yml index 37ec30df..3a6a8642 100644 --- a/.github/actions/ci-setup/action.yml +++ b/.github/actions/ci-setup/action.yml @@ -43,5 +43,5 @@ runs: coverage: xdebug - name: Install dependencies if: ${{ inputs.install-deps == 'yes' }} - run: composer install --no-interaction --prefer-dist ${{ inputs.php-version == '8.4' && '--ignore-platform-req=php' || '' }} + run: composer install --no-interaction --prefer-dist shell: bash From 8298ef36f8191461dd7baa45e2d29e9239152ba8 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 18 Nov 2024 09:51:27 +0100 Subject: [PATCH 60/80] Use more meaningful domain exceptions to represent ApiKeyService thrown errors --- .../src/Exception/ApiKeyConflictException.php | 15 ++++++++++ .../src/Exception/ApiKeyNotFoundException.php | 21 +++++++++++++ module/Rest/src/Service/ApiKeyService.php | 30 +++++++++++-------- .../src/Service/ApiKeyServiceInterface.php | 9 ++++-- .../Rest/test/Service/ApiKeyServiceTest.php | 12 ++++---- 5 files changed, 66 insertions(+), 21 deletions(-) create mode 100644 module/Rest/src/Exception/ApiKeyConflictException.php create mode 100644 module/Rest/src/Exception/ApiKeyNotFoundException.php diff --git a/module/Rest/src/Exception/ApiKeyConflictException.php b/module/Rest/src/Exception/ApiKeyConflictException.php new file mode 100644 index 00000000..a1ffce03 --- /dev/null +++ b/module/Rest/src/Exception/ApiKeyConflictException.php @@ -0,0 +1,15 @@ +disableApiKey($this->repo->findOneBy(['name' => $apiKeyName])); + $apiKey = $this->repo->findOneBy(['name' => $apiKeyName]); + if ($apiKey === null) { + throw ApiKeyNotFoundException::forName($apiKeyName); + } + + return $this->disableApiKey($apiKey); } /** @@ -79,15 +86,16 @@ readonly class ApiKeyService implements ApiKeyServiceInterface */ public function disableByKey(string $key): ApiKey { - return $this->disableApiKey($this->findByKey($key)); - } - - private function disableApiKey(ApiKey|null $apiKey): ApiKey - { + $apiKey = $this->findByKey($key); if ($apiKey === null) { - throw new InvalidArgumentException('Provided API key does not exist and can\'t be disabled'); + throw ApiKeyNotFoundException::forKey($key); } + return $this->disableApiKey($apiKey); + } + + private function disableApiKey(ApiKey $apiKey): ApiKey + { $apiKey->disable(); $this->em->flush(); @@ -110,9 +118,7 @@ readonly class ApiKeyService implements ApiKeyServiceInterface { $apiKey = $this->repo->findOneBy(['name' => $apiKeyRenaming->oldName]); if ($apiKey === null) { - throw new InvalidArgumentException( - sprintf('API key with name "%s" could not be found', $apiKeyRenaming->oldName), - ); + throw ApiKeyNotFoundException::forName($apiKeyRenaming->oldName); } if (! $apiKeyRenaming->nameChanged()) { @@ -121,9 +127,7 @@ readonly class ApiKeyService implements ApiKeyServiceInterface $this->em->wrapInTransaction(function () use ($apiKeyRenaming, $apiKey): void { if ($this->repo->nameExists($apiKeyRenaming->newName)) { - throw new InvalidArgumentException( - sprintf('Another API key with name "%s" already exists', $apiKeyRenaming->newName), - ); + throw ApiKeyConflictException::forName($apiKeyRenaming->newName); } $apiKey->name = $apiKeyRenaming->newName; diff --git a/module/Rest/src/Service/ApiKeyServiceInterface.php b/module/Rest/src/Service/ApiKeyServiceInterface.php index be7b9191..745355d7 100644 --- a/module/Rest/src/Service/ApiKeyServiceInterface.php +++ b/module/Rest/src/Service/ApiKeyServiceInterface.php @@ -8,6 +8,8 @@ use Shlinkio\Shlink\Common\Exception\InvalidArgumentException; use Shlinkio\Shlink\Core\Model\Renaming; use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\Entity\ApiKey; +use Shlinkio\Shlink\Rest\Exception\ApiKeyConflictException; +use Shlinkio\Shlink\Rest\Exception\ApiKeyNotFoundException; interface ApiKeyServiceInterface { @@ -21,13 +23,13 @@ interface ApiKeyServiceInterface public function check(string $key): ApiKeyCheckResult; /** - * @throws InvalidArgumentException + * @throws ApiKeyNotFoundException */ public function disableByName(string $apiKeyName): ApiKey; /** * @deprecated Use `self::disableByName($name)` instead - * @throws InvalidArgumentException + * @throws ApiKeyNotFoundException */ public function disableByKey(string $key): ApiKey; @@ -37,7 +39,8 @@ interface ApiKeyServiceInterface public function listKeys(bool $enabledOnly = false): array; /** - * @throws InvalidArgumentException If an API key with oldName does not exist, or newName is in use by another one + * @throws ApiKeyNotFoundException + * @throws ApiKeyConflictException */ public function renameApiKey(Renaming $apiKeyRenaming): ApiKey; } diff --git a/module/Rest/test/Service/ApiKeyServiceTest.php b/module/Rest/test/Service/ApiKeyServiceTest.php index bf80ae60..81bec4ea 100644 --- a/module/Rest/test/Service/ApiKeyServiceTest.php +++ b/module/Rest/test/Service/ApiKeyServiceTest.php @@ -17,6 +17,8 @@ use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; use Shlinkio\Shlink\Rest\ApiKey\Repository\ApiKeyRepositoryInterface; use Shlinkio\Shlink\Rest\Entity\ApiKey; +use Shlinkio\Shlink\Rest\Exception\ApiKeyConflictException; +use Shlinkio\Shlink\Rest\Exception\ApiKeyNotFoundException; use Shlinkio\Shlink\Rest\Service\ApiKeyService; use function substr; @@ -145,7 +147,7 @@ class ApiKeyServiceTest extends TestCase { $this->repo->expects($this->once())->method('findOneBy')->with($findOneByArg)->willReturn(null); - $this->expectException(InvalidArgumentException::class); + $this->expectException(ApiKeyNotFoundException::class); $this->service->{$disableMethod}('12345'); } @@ -217,8 +219,8 @@ class ApiKeyServiceTest extends TestCase $this->repo->expects($this->once())->method('findOneBy')->with(['name' => 'old'])->willReturn(null); $this->repo->expects($this->never())->method('nameExists'); - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('API key with name "old" could not be found'); + $this->expectException(ApiKeyNotFoundException::class); + $this->expectExceptionMessage('API key with name "old" not found'); $this->service->renameApiKey($renaming); } @@ -246,8 +248,8 @@ class ApiKeyServiceTest extends TestCase $this->repo->expects($this->once())->method('findOneBy')->with(['name' => 'old'])->willReturn($apiKey); $this->repo->expects($this->once())->method('nameExists')->with('new')->willReturn(true); - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Another API key with name "new" already exists'); + $this->expectException(ApiKeyConflictException::class); + $this->expectExceptionMessage('An API key with name "new" already exists'); $this->service->renameApiKey($renaming); } From fa08014226c0c91d6b824390c214ca51cdb37a00 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 19 Nov 2024 09:08:04 +0100 Subject: [PATCH 61/80] Make sure IpGeolocationMiddleware skips localhost --- .../Geolocation/Middleware/IpGeolocationMiddleware.php | 5 ++++- module/Core/src/Visit/Geolocation/VisitLocator.php | 2 +- .../Middleware/IpGeolocationMiddlewareTest.php | 10 ++++++++-- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/module/Core/src/Geolocation/Middleware/IpGeolocationMiddleware.php b/module/Core/src/Geolocation/Middleware/IpGeolocationMiddleware.php index 4e2e533b..f5657e64 100644 --- a/module/Core/src/Geolocation/Middleware/IpGeolocationMiddleware.php +++ b/module/Core/src/Geolocation/Middleware/IpGeolocationMiddleware.php @@ -9,6 +9,7 @@ use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; use Psr\Log\LoggerInterface; +use Shlinkio\Shlink\Common\Util\IpAddress; use Shlinkio\Shlink\Core\Config\Options\TrackingOptions; use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException; use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdaterInterface; @@ -46,7 +47,9 @@ readonly class IpGeolocationMiddleware implements MiddlewareInterface private function geolocateIpAddress(string|null $ipAddress): Location { try { - return $ipAddress === null ? Location::empty() : $this->ipLocationResolver->resolveIpLocation($ipAddress); + return $ipAddress === null || $ipAddress === IpAddress::LOCALHOST + ? Location::empty() + : $this->ipLocationResolver->resolveIpLocation($ipAddress); } catch (WrongIpException $e) { $this->logger->warning('Tried to locate IP address, but it seems to be wrong. {e}', ['e' => $e]); return Location::empty(); diff --git a/module/Core/src/Visit/Geolocation/VisitLocator.php b/module/Core/src/Visit/Geolocation/VisitLocator.php index f3aba193..8f69ba2c 100644 --- a/module/Core/src/Visit/Geolocation/VisitLocator.php +++ b/module/Core/src/Visit/Geolocation/VisitLocator.php @@ -54,7 +54,7 @@ readonly class VisitLocator implements VisitLocatorInterface } // If the IP address is non-locatable, locate it as empty to prevent next processes to pick it again - $location = Location::emptyInstance(); + $location = Location::empty(); } $this->locateVisit($visit, VisitLocation::fromGeolocation($location), $helper); diff --git a/module/Core/test/Geolocation/Middleware/IpGeolocationMiddlewareTest.php b/module/Core/test/Geolocation/Middleware/IpGeolocationMiddlewareTest.php index f203fb85..80768f5b 100644 --- a/module/Core/test/Geolocation/Middleware/IpGeolocationMiddlewareTest.php +++ b/module/Core/test/Geolocation/Middleware/IpGeolocationMiddlewareTest.php @@ -15,6 +15,7 @@ use Psr\Http\Server\RequestHandlerInterface; use Psr\Log\LoggerInterface; use RuntimeException; use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory; +use Shlinkio\Shlink\Common\Util\IpAddress; use Shlinkio\Shlink\Core\Config\Options\TrackingOptions; use Shlinkio\Shlink\Core\Geolocation\Middleware\IpGeolocationMiddleware; use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException; @@ -67,13 +68,18 @@ class IpGeolocationMiddlewareTest extends TestCase } #[Test] - public function emptyLocationIsReturnedIfIpAddressDoesNotExistInRequest(): void + #[TestWith([null])] + #[TestWith([IpAddress::LOCALHOST])] + public function emptyLocationIsReturnedIfIpAddressIsNotLocatable(string|null $ipAddress): void { $this->dbUpdater->expects($this->once())->method('databaseFileExists')->willReturn(true); $this->ipLocationResolver->expects($this->never())->method('resolveIpLocation'); $this->logger->expects($this->never())->method('warning'); - $request = ServerRequestFactory::fromGlobals(); + $request = ServerRequestFactory::fromGlobals()->withAttribute( + IpAddressMiddlewareFactory::REQUEST_ATTR, + $ipAddress, + ); $this->handler->expects($this->once())->method('handle')->with($this->callback( function (ServerRequestInterface $req): bool { $location = $req->getAttribute(Location::class); From f57f159002c19a76569e49e5d86a32485ef15155 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 19 Nov 2024 09:10:47 +0100 Subject: [PATCH 62/80] Remove no longer used Visit::isLocatable method --- module/Core/src/Visit/Entity/Visit.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/module/Core/src/Visit/Entity/Visit.php b/module/Core/src/Visit/Entity/Visit.php index e26d5f80..9e8540bc 100644 --- a/module/Core/src/Visit/Entity/Visit.php +++ b/module/Core/src/Visit/Entity/Visit.php @@ -127,11 +127,6 @@ class Visit extends AbstractEntity implements JsonSerializable return $this->visitLocation; } - public function isLocatable(): bool - { - return $this->hasRemoteAddr() && $this->remoteAddr !== IpAddress::LOCALHOST; - } - public function locate(VisitLocation $visitLocation): self { $this->visitLocation = $visitLocation; From a56ff1293eb60afef19760c8c67d00dbc42acbab Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 19 Nov 2024 09:18:06 +0100 Subject: [PATCH 63/80] Remove direct dependency on laminas/laminas-config --- composer.json | 1 - 1 file changed, 1 deletion(-) diff --git a/composer.json b/composer.json index 76d94a07..b680207d 100644 --- a/composer.json +++ b/composer.json @@ -29,7 +29,6 @@ "guzzlehttp/guzzle": "^7.9", "hidehalo/nanoid-php": "^2.0", "jaybizzle/crawler-detect": "^1.2.116", - "laminas/laminas-config": "^3.9", "laminas/laminas-config-aggregator": "^1.15", "laminas/laminas-diactoros": "^3.5", "laminas/laminas-inputfilter": "^2.30", From 81bed53f90a57ca83f9b1debab69d8f0e1796234 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 19 Nov 2024 20:12:38 +0100 Subject: [PATCH 64/80] Update Shlink libraries to remove dependency on laminas-config --- composer.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index b680207d..c880ac36 100644 --- a/composer.json +++ b/composer.json @@ -43,11 +43,11 @@ "pagerfanta/core": "^3.8", "ramsey/uuid": "^4.7", "shlinkio/doctrine-specification": "^2.1.1", - "shlinkio/shlink-common": "^6.5", - "shlinkio/shlink-config": "^3.3", + "shlinkio/shlink-common": "dev-main#698f580 as 6.6", + "shlinkio/shlink-config": "dev-main#e7dbed3 as 3.4", "shlinkio/shlink-event-dispatcher": "^4.1", "shlinkio/shlink-importer": "^5.3.2", - "shlinkio/shlink-installer": "^9.2", + "shlinkio/shlink-installer": "dev-develop#b7503ad as 9.3", "shlinkio/shlink-ip-geolocation": "dev-main#fadae5d as 4.2", "shlinkio/shlink-json": "^1.1", "spiral/roadrunner": "^2024.1", From d7e300e2d5f5839b208f3431cf0c6b2ce6576fdd Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 20 Nov 2024 09:45:18 +0100 Subject: [PATCH 65/80] Reduce duplication in actions listing visits --- .../src/Visit/Model/OrphanVisitsParams.php | 3 +- .../OrphanVisitsPaginatorAdapterTest.php | 2 +- .../Action/Visit/AbstractListVisitsAction.php | 44 +++++++++++++++++++ .../src/Action/Visit/DomainVisitsAction.php | 21 +++------ .../Action/Visit/NonOrphanVisitsAction.php | 28 ++++-------- .../src/Action/Visit/OrphanVisitsAction.php | 30 +++++-------- .../src/Action/Visit/ShortUrlVisitsAction.php | 23 +++------- .../Rest/src/Action/Visit/TagVisitsAction.php | 23 +++------- 8 files changed, 83 insertions(+), 91 deletions(-) create mode 100644 module/Rest/src/Action/Visit/AbstractListVisitsAction.php diff --git a/module/Core/src/Visit/Model/OrphanVisitsParams.php b/module/Core/src/Visit/Model/OrphanVisitsParams.php index 0e6afedc..6991928d 100644 --- a/module/Core/src/Visit/Model/OrphanVisitsParams.php +++ b/module/Core/src/Visit/Model/OrphanVisitsParams.php @@ -21,9 +21,8 @@ final class OrphanVisitsParams extends VisitsParams parent::__construct($dateRange, $page, $itemsPerPage, $excludeBots); } - public static function fromRawData(array $query): self + public static function fromVisitsParamsAndRawData(VisitsParams $visitsParams, array $query): self { - $visitsParams = parent::fromRawData($query); $type = $query['type'] ?? null; return new self( diff --git a/module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php b/module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php index abad2fc0..b62fa0c6 100644 --- a/module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php +++ b/module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php @@ -27,7 +27,7 @@ class OrphanVisitsPaginatorAdapterTest extends TestCase protected function setUp(): void { $this->repo = $this->createMock(VisitRepositoryInterface::class); - $this->params = OrphanVisitsParams::fromRawData([]); + $this->params = new OrphanVisitsParams(); $this->apiKey = ApiKey::create(); $this->adapter = new OrphanVisitsPaginatorAdapter($this->repo, $this->params, $this->apiKey); diff --git a/module/Rest/src/Action/Visit/AbstractListVisitsAction.php b/module/Rest/src/Action/Visit/AbstractListVisitsAction.php new file mode 100644 index 00000000..090d3078 --- /dev/null +++ b/module/Rest/src/Action/Visit/AbstractListVisitsAction.php @@ -0,0 +1,44 @@ +getQueryParams()); + $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); + $visits = $this->getVisitsPaginator($request, $params, $apiKey); + + return new JsonResponse(['visits' => PagerfantaUtils::serializePaginator($visits)]); + } + + /** + * @return Pagerfanta + */ + abstract protected function getVisitsPaginator( + ServerRequestInterface $request, + VisitsParams $params, + ApiKey $apiKey, + ): Pagerfanta; +} diff --git a/module/Rest/src/Action/Visit/DomainVisitsAction.php b/module/Rest/src/Action/Visit/DomainVisitsAction.php index fc9cf20c..ee1625e0 100644 --- a/module/Rest/src/Action/Visit/DomainVisitsAction.php +++ b/module/Rest/src/Action/Visit/DomainVisitsAction.php @@ -4,36 +4,29 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Rest\Action\Visit; -use Laminas\Diactoros\Response\JsonResponse; -use Psr\Http\Message\ResponseInterface as Response; +use Pagerfanta\Pagerfanta; use Psr\Http\Message\ServerRequestInterface as Request; -use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtils; use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; -use Shlinkio\Shlink\Rest\Action\AbstractRestAction; -use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; +use Shlinkio\Shlink\Rest\Entity\ApiKey; -class DomainVisitsAction extends AbstractRestAction +class DomainVisitsAction extends AbstractListVisitsAction { protected const ROUTE_PATH = '/domains/{domain}/visits'; - protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; public function __construct( - private readonly VisitsStatsHelperInterface $visitsHelper, + VisitsStatsHelperInterface $visitsHelper, private readonly UrlShortenerOptions $urlShortenerOptions, ) { + parent::__construct($visitsHelper); } - public function handle(Request $request): Response + protected function getVisitsPaginator(Request $request, VisitsParams $params, ApiKey $apiKey): Pagerfanta { $domain = $this->resolveDomainParam($request); - $params = VisitsParams::fromRawData($request->getQueryParams()); - $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); - $visits = $this->visitsHelper->visitsForDomain($domain, $params, $apiKey); - - return new JsonResponse(['visits' => PagerfantaUtils::serializePaginator($visits)]); + return $this->visitsHelper->visitsForDomain($domain, $params, $apiKey); } private function resolveDomainParam(Request $request): string diff --git a/module/Rest/src/Action/Visit/NonOrphanVisitsAction.php b/module/Rest/src/Action/Visit/NonOrphanVisitsAction.php index 1fffdb8b..8406b515 100644 --- a/module/Rest/src/Action/Visit/NonOrphanVisitsAction.php +++ b/module/Rest/src/Action/Visit/NonOrphanVisitsAction.php @@ -4,30 +4,20 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Rest\Action\Visit; -use Laminas\Diactoros\Response\JsonResponse; -use Psr\Http\Message\ResponseInterface; +use Pagerfanta\Pagerfanta; use Psr\Http\Message\ServerRequestInterface; -use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtils; use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; -use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; -use Shlinkio\Shlink\Rest\Action\AbstractRestAction; -use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; +use Shlinkio\Shlink\Rest\Entity\ApiKey; -class NonOrphanVisitsAction extends AbstractRestAction +class NonOrphanVisitsAction extends AbstractListVisitsAction { protected const ROUTE_PATH = '/visits/non-orphan'; - protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; - public function __construct(private readonly VisitsStatsHelperInterface $visitsHelper) - { - } - - public function handle(ServerRequestInterface $request): ResponseInterface - { - $params = VisitsParams::fromRawData($request->getQueryParams()); - $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); - $visits = $this->visitsHelper->nonOrphanVisits($params, $apiKey); - - return new JsonResponse(['visits' => PagerfantaUtils::serializePaginator($visits)]); + protected function getVisitsPaginator( + ServerRequestInterface $request, + VisitsParams $params, + ApiKey $apiKey, + ): Pagerfanta { + return $this->visitsHelper->nonOrphanVisits($params, $apiKey); } } diff --git a/module/Rest/src/Action/Visit/OrphanVisitsAction.php b/module/Rest/src/Action/Visit/OrphanVisitsAction.php index 7906fdae..341524c3 100644 --- a/module/Rest/src/Action/Visit/OrphanVisitsAction.php +++ b/module/Rest/src/Action/Visit/OrphanVisitsAction.php @@ -4,30 +4,22 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Rest\Action\Visit; -use Laminas\Diactoros\Response\JsonResponse; -use Psr\Http\Message\ResponseInterface; +use Pagerfanta\Pagerfanta; use Psr\Http\Message\ServerRequestInterface; -use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtils; use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams; -use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; -use Shlinkio\Shlink\Rest\Action\AbstractRestAction; -use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; +use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; +use Shlinkio\Shlink\Rest\Entity\ApiKey; -class OrphanVisitsAction extends AbstractRestAction +class OrphanVisitsAction extends AbstractListVisitsAction { protected const ROUTE_PATH = '/visits/orphan'; - protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; - public function __construct(private readonly VisitsStatsHelperInterface $visitsHelper) - { - } - - public function handle(ServerRequestInterface $request): ResponseInterface - { - $params = OrphanVisitsParams::fromRawData($request->getQueryParams()); - $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); - $visits = $this->visitsHelper->orphanVisits($params, $apiKey); - - return new JsonResponse(['visits' => PagerfantaUtils::serializePaginator($visits)]); + protected function getVisitsPaginator( + ServerRequestInterface $request, + VisitsParams $params, + ApiKey $apiKey, + ): Pagerfanta { + $orphanParams = OrphanVisitsParams::fromVisitsParamsAndRawData($params, $request->getQueryParams()); + return $this->visitsHelper->orphanVisits($orphanParams, $apiKey); } } diff --git a/module/Rest/src/Action/Visit/ShortUrlVisitsAction.php b/module/Rest/src/Action/Visit/ShortUrlVisitsAction.php index fe5099a2..95ac42cc 100644 --- a/module/Rest/src/Action/Visit/ShortUrlVisitsAction.php +++ b/module/Rest/src/Action/Visit/ShortUrlVisitsAction.php @@ -4,32 +4,19 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Rest\Action\Visit; -use Laminas\Diactoros\Response\JsonResponse; -use Psr\Http\Message\ResponseInterface as Response; +use Pagerfanta\Pagerfanta; use Psr\Http\Message\ServerRequestInterface as Request; -use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtils; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; -use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; -use Shlinkio\Shlink\Rest\Action\AbstractRestAction; -use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; +use Shlinkio\Shlink\Rest\Entity\ApiKey; -class ShortUrlVisitsAction extends AbstractRestAction +class ShortUrlVisitsAction extends AbstractListVisitsAction { protected const ROUTE_PATH = '/short-urls/{shortCode}/visits'; - protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; - public function __construct(private readonly VisitsStatsHelperInterface $visitsHelper) - { - } - - public function handle(Request $request): Response + protected function getVisitsPaginator(Request $request, VisitsParams $params, ApiKey $apiKey): Pagerfanta { $identifier = ShortUrlIdentifier::fromApiRequest($request); - $params = VisitsParams::fromRawData($request->getQueryParams()); - $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); - $visits = $this->visitsHelper->visitsForShortUrl($identifier, $params, $apiKey); - - return new JsonResponse(['visits' => PagerfantaUtils::serializePaginator($visits)]); + return $this->visitsHelper->visitsForShortUrl($identifier, $params, $apiKey); } } diff --git a/module/Rest/src/Action/Visit/TagVisitsAction.php b/module/Rest/src/Action/Visit/TagVisitsAction.php index 1739264f..08553ec5 100644 --- a/module/Rest/src/Action/Visit/TagVisitsAction.php +++ b/module/Rest/src/Action/Visit/TagVisitsAction.php @@ -4,31 +4,18 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Rest\Action\Visit; -use Laminas\Diactoros\Response\JsonResponse; -use Psr\Http\Message\ResponseInterface as Response; +use Pagerfanta\Pagerfanta; use Psr\Http\Message\ServerRequestInterface as Request; -use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtils; use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; -use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; -use Shlinkio\Shlink\Rest\Action\AbstractRestAction; -use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; +use Shlinkio\Shlink\Rest\Entity\ApiKey; -class TagVisitsAction extends AbstractRestAction +class TagVisitsAction extends AbstractListVisitsAction { protected const ROUTE_PATH = '/tags/{tag}/visits'; - protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; - public function __construct(private readonly VisitsStatsHelperInterface $visitsHelper) - { - } - - public function handle(Request $request): Response + protected function getVisitsPaginator(Request $request, VisitsParams $params, ApiKey $apiKey): Pagerfanta { $tag = $request->getAttribute('tag', ''); - $params = VisitsParams::fromRawData($request->getQueryParams()); - $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); - $visits = $this->visitsHelper->visitsForTag($tag, $params, $apiKey); - - return new JsonResponse(['visits' => PagerfantaUtils::serializePaginator($visits)]); + return $this->visitsHelper->visitsForTag($tag, $params, $apiKey); } } From 2946b630c55d6003fe5172c426690bffef587ad1 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 22 Nov 2024 08:59:42 +0100 Subject: [PATCH 66/80] Use IpAddressFactory from akrabat/ip-address-middleware --- composer.json | 2 +- config/autoload/client-detection.global.php | 20 ---------- config/autoload/ip-address.global.php | 37 +++++++++++++++++++ config/constants.php | 1 + module/Core/functions/functions.php | 5 ++- module/Core/test-api/Action/RedirectTest.php | 4 +- .../IpGeolocationMiddlewareTest.php | 18 +++------ .../Entity/RedirectConditionTest.php | 4 +- .../ShortUrlRedirectionResolverTest.php | 10 ++--- module/Core/test/Visit/RequestTrackerTest.php | 11 +++--- 10 files changed, 62 insertions(+), 50 deletions(-) delete mode 100644 config/autoload/client-detection.global.php create mode 100644 config/autoload/ip-address.global.php diff --git a/composer.json b/composer.json index c880ac36..6cc93738 100644 --- a/composer.json +++ b/composer.json @@ -43,7 +43,7 @@ "pagerfanta/core": "^3.8", "ramsey/uuid": "^4.7", "shlinkio/doctrine-specification": "^2.1.1", - "shlinkio/shlink-common": "dev-main#698f580 as 6.6", + "shlinkio/shlink-common": "dev-main#abdad29 as 6.6", "shlinkio/shlink-config": "dev-main#e7dbed3 as 3.4", "shlinkio/shlink-event-dispatcher": "^4.1", "shlinkio/shlink-importer": "^5.3.2", diff --git a/config/autoload/client-detection.global.php b/config/autoload/client-detection.global.php deleted file mode 100644 index a49b3d93..00000000 --- a/config/autoload/client-detection.global.php +++ /dev/null @@ -1,20 +0,0 @@ - [ - 'headers_to_inspect' => [ - 'CF-Connecting-IP', - 'X-Forwarded-For', - 'X-Forwarded', - 'Forwarded', - 'True-Client-IP', - 'X-Real-IP', - 'X-Cluster-Client-Ip', - 'Client-Ip', - ], - ], - -]; diff --git a/config/autoload/ip-address.global.php b/config/autoload/ip-address.global.php new file mode 100644 index 00000000..9d531040 --- /dev/null +++ b/config/autoload/ip-address.global.php @@ -0,0 +1,37 @@ + [ + 'ip_address' => [ + 'attribute_name' => IP_ADDRESS_REQUEST_ATTRIBUTE, + 'check_proxy_headers' => true, + 'trusted_proxies' => [], + 'headers_to_inspect' => [ + 'CF-Connecting-IP', + 'X-Forwarded-For', + 'X-Forwarded', + 'Forwarded', + 'True-Client-IP', + 'X-Real-IP', + 'X-Cluster-Client-Ip', + 'Client-Ip', + ], + ], + ], + + 'dependencies' => [ + 'factories' => [ + IpAddress::class => IpAddressFactory::class, + ], + ], + +]; diff --git a/config/constants.php b/config/constants.php index 20c64f19..d6bb9621 100644 --- a/config/constants.php +++ b/config/constants.php @@ -21,3 +21,4 @@ const DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = true; const DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS = true; const DEFAULT_QR_CODE_COLOR = '#000000'; // Black const DEFAULT_QR_CODE_BG_COLOR = '#ffffff'; // White +const IP_ADDRESS_REQUEST_ATTRIBUTE = 'remote_address'; diff --git a/module/Core/functions/functions.php b/module/Core/functions/functions.php index 6ccc42e2..513e885d 100644 --- a/module/Core/functions/functions.php +++ b/module/Core/functions/functions.php @@ -15,7 +15,6 @@ use Laminas\Filter\Word\CamelCaseToSeparator; use Laminas\Filter\Word\CamelCaseToUnderscore; use Laminas\InputFilter\InputFilter; use Psr\Http\Message\ServerRequestInterface; -use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; use Shlinkio\Shlink\IpGeolocation\Model\Location; @@ -38,6 +37,8 @@ use function strtolower; use function trim; use function ucfirst; +use const Shlinkio\Shlink\IP_ADDRESS_REQUEST_ATTRIBUTE; + function generateRandomShortCode(int $length, ShortUrlMode $mode = ShortUrlMode::STRICT): string { static $nanoIdClient; @@ -288,7 +289,7 @@ function splitByComma(string|null $value): array function ipAddressFromRequest(ServerRequestInterface $request): string|null { - return $request->getAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR); + return $request->getAttribute(IP_ADDRESS_REQUEST_ATTRIBUTE); } function geolocationFromRequest(ServerRequestInterface $request): Location|null diff --git a/module/Core/test-api/Action/RedirectTest.php b/module/Core/test-api/Action/RedirectTest.php index 36031da8..79a13fbf 100644 --- a/module/Core/test-api/Action/RedirectTest.php +++ b/module/Core/test-api/Action/RedirectTest.php @@ -89,8 +89,8 @@ class RedirectTest extends ApiTestCase 'https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/', ]; - $clientDetection = require __DIR__ . '/../../../../config/autoload/client-detection.global.php'; - foreach ($clientDetection['ip_address_resolution']['headers_to_inspect'] as $header) { + $ipAddressConfig = require __DIR__ . '/../../../../config/autoload/ip-address.global.php'; + foreach ($ipAddressConfig['rka']['ip_address']['headers_to_inspect'] as $header) { yield sprintf('rule: IP address in "%s" header', $header) => [ [ RequestOptions::HEADERS => [$header => '1.2.3.4'], diff --git a/module/Core/test/Geolocation/Middleware/IpGeolocationMiddlewareTest.php b/module/Core/test/Geolocation/Middleware/IpGeolocationMiddlewareTest.php index 80768f5b..210fb46f 100644 --- a/module/Core/test/Geolocation/Middleware/IpGeolocationMiddlewareTest.php +++ b/module/Core/test/Geolocation/Middleware/IpGeolocationMiddlewareTest.php @@ -14,7 +14,6 @@ use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; use Psr\Log\LoggerInterface; use RuntimeException; -use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory; use Shlinkio\Shlink\Common\Util\IpAddress; use Shlinkio\Shlink\Core\Config\Options\TrackingOptions; use Shlinkio\Shlink\Core\Geolocation\Middleware\IpGeolocationMiddleware; @@ -24,6 +23,8 @@ use Shlinkio\Shlink\IpGeolocation\Model\Location; use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; use Throwable; +use const Shlinkio\Shlink\IP_ADDRESS_REQUEST_ATTRIBUTE; + class IpGeolocationMiddlewareTest extends TestCase { private MockObject & IpLocationResolverInterface $ipLocationResolver; @@ -76,10 +77,7 @@ class IpGeolocationMiddlewareTest extends TestCase $this->ipLocationResolver->expects($this->never())->method('resolveIpLocation'); $this->logger->expects($this->never())->method('warning'); - $request = ServerRequestFactory::fromGlobals()->withAttribute( - IpAddressMiddlewareFactory::REQUEST_ATTR, - $ipAddress, - ); + $request = ServerRequestFactory::fromGlobals()->withAttribute(IP_ADDRESS_REQUEST_ATTRIBUTE, $ipAddress); $this->handler->expects($this->once())->method('handle')->with($this->callback( function (ServerRequestInterface $req): bool { $location = $req->getAttribute(Location::class); @@ -104,10 +102,7 @@ class IpGeolocationMiddlewareTest extends TestCase ); $this->logger->expects($this->never())->method('warning'); - $request = ServerRequestFactory::fromGlobals()->withAttribute( - IpAddressMiddlewareFactory::REQUEST_ATTR, - '1.2.3.4', - ); + $request = ServerRequestFactory::fromGlobals()->withAttribute(IP_ADDRESS_REQUEST_ATTRIBUTE, '1.2.3.4'); $this->handler->expects($this->once())->method('handle')->with($this->callback( function (ServerRequestInterface $req): bool { $location = $req->getAttribute(Location::class); @@ -147,10 +142,7 @@ class IpGeolocationMiddlewareTest extends TestCase ->willThrowException($exception); $this->logger->expects($this->once())->method($loggerMethod)->with($expectedLoggedMessage, ['e' => $exception]); - $request = ServerRequestFactory::fromGlobals()->withAttribute( - IpAddressMiddlewareFactory::REQUEST_ATTR, - '1.2.3.4', - ); + $request = ServerRequestFactory::fromGlobals()->withAttribute(IP_ADDRESS_REQUEST_ATTRIBUTE, '1.2.3.4'); $this->handler->expects($this->once())->method('handle')->with($this->callback( function (ServerRequestInterface $req): bool { $location = $req->getAttribute(Location::class); diff --git a/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php b/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php index 2ae5df18..5a4a2e2b 100644 --- a/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php +++ b/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php @@ -7,11 +7,11 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; -use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory; use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\RedirectRule\Entity\RedirectCondition; use Shlinkio\Shlink\IpGeolocation\Model\Location; +use const Shlinkio\Shlink\IP_ADDRESS_REQUEST_ATTRIBUTE; use const ShlinkioTest\Shlink\ANDROID_USER_AGENT; use const ShlinkioTest\Shlink\DESKTOP_USER_AGENT; use const ShlinkioTest\Shlink\IOS_USER_AGENT; @@ -88,7 +88,7 @@ class RedirectConditionTest extends TestCase { $request = ServerRequestFactory::fromGlobals(); if ($remoteIp !== null) { - $request = $request->withAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR, $remoteIp); + $request = $request->withAttribute(IP_ADDRESS_REQUEST_ATTRIBUTE, $remoteIp); } $result = RedirectCondition::forIpAddress($ipToMatch)->matchesRequest($request); diff --git a/module/Core/test/RedirectRule/ShortUrlRedirectionResolverTest.php b/module/Core/test/RedirectRule/ShortUrlRedirectionResolverTest.php index f26627c6..470ff95e 100644 --- a/module/Core/test/RedirectRule/ShortUrlRedirectionResolverTest.php +++ b/module/Core/test/RedirectRule/ShortUrlRedirectionResolverTest.php @@ -9,7 +9,6 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; -use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory; use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\RedirectRule\Entity\RedirectCondition; use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule; @@ -18,6 +17,7 @@ use Shlinkio\Shlink\Core\RedirectRule\ShortUrlRedirectRuleServiceInterface; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; +use const Shlinkio\Shlink\IP_ADDRESS_REQUEST_ATTRIBUTE; use const ShlinkioTest\Shlink\ANDROID_USER_AGENT; use const ShlinkioTest\Shlink\DESKTOP_USER_AGENT; use const ShlinkioTest\Shlink\IOS_USER_AGENT; @@ -90,22 +90,22 @@ class ShortUrlRedirectionResolverTest extends TestCase 'https://example.com/from-rule', ]; yield 'matching static IP address' => [ - $request()->withAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR, '1.2.3.4'), + $request()->withAttribute(IP_ADDRESS_REQUEST_ATTRIBUTE, '1.2.3.4'), RedirectCondition::forIpAddress('1.2.3.4'), 'https://example.com/from-rule', ]; yield 'matching CIDR block' => [ - $request()->withAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR, '192.168.1.35'), + $request()->withAttribute(IP_ADDRESS_REQUEST_ATTRIBUTE, '192.168.1.35'), RedirectCondition::forIpAddress('192.168.1.0/24'), 'https://example.com/from-rule', ]; yield 'matching wildcard IP address' => [ - $request()->withAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR, '1.2.5.5'), + $request()->withAttribute(IP_ADDRESS_REQUEST_ATTRIBUTE, '1.2.5.5'), RedirectCondition::forIpAddress('1.2.*.*'), 'https://example.com/from-rule', ]; yield 'non-matching IP address' => [ - $request()->withAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR, '4.3.2.1'), + $request()->withAttribute(IP_ADDRESS_REQUEST_ATTRIBUTE, '4.3.2.1'), RedirectCondition::forIpAddress('1.2.3.4'), 'https://example.com/foo/bar', ]; diff --git a/module/Core/test/Visit/RequestTrackerTest.php b/module/Core/test/Visit/RequestTrackerTest.php index ae0a74c4..f9357c6a 100644 --- a/module/Core/test/Visit/RequestTrackerTest.php +++ b/module/Core/test/Visit/RequestTrackerTest.php @@ -12,7 +12,6 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; -use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory; use Shlinkio\Shlink\Core\Config\Options\TrackingOptions; use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; @@ -20,6 +19,8 @@ use Shlinkio\Shlink\Core\Visit\Model\Visitor; use Shlinkio\Shlink\Core\Visit\RequestTracker; use Shlinkio\Shlink\Core\Visit\VisitsTrackerInterface; +use const Shlinkio\Shlink\IP_ADDRESS_REQUEST_ATTRIBUTE; + class RequestTrackerTest extends TestCase { private const LONG_URL = 'https://domain.com/foo/bar?some=thing'; @@ -67,15 +68,15 @@ class RequestTrackerTest extends TestCase ServerRequestFactory::fromGlobals()->withQueryParams(['foobar' => null]), ]; yield 'exact remote address' => [ServerRequestFactory::fromGlobals()->withAttribute( - IpAddressMiddlewareFactory::REQUEST_ATTR, + IP_ADDRESS_REQUEST_ATTRIBUTE, '80.90.100.110', )]; yield 'matching wildcard remote address' => [ServerRequestFactory::fromGlobals()->withAttribute( - IpAddressMiddlewareFactory::REQUEST_ATTR, + IP_ADDRESS_REQUEST_ATTRIBUTE, '1.2.3.4', )]; yield 'matching CIDR block remote address' => [ServerRequestFactory::fromGlobals()->withAttribute( - IpAddressMiddlewareFactory::REQUEST_ATTR, + IP_ADDRESS_REQUEST_ATTRIBUTE, '192.168.10.100', )]; } @@ -102,7 +103,7 @@ class RequestTrackerTest extends TestCase ); $this->requestTracker->trackIfApplicable($shortUrl, ServerRequestFactory::fromGlobals()->withAttribute( - IpAddressMiddlewareFactory::REQUEST_ATTR, + IP_ADDRESS_REQUEST_ATTRIBUTE, 'invalid', )); } From fbf1aabcf513d4d34cc4680b43b7539f1e0472dc Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 24 Nov 2024 10:49:44 +0100 Subject: [PATCH 67/80] Replace jaybizzle/crawler-detect with acelaya/crawler-detect --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 6cc93738..2515e9f2 100644 --- a/composer.json +++ b/composer.json @@ -18,6 +18,7 @@ "ext-json": "*", "ext-mbstring": "*", "ext-pdo": "*", + "acelaya/crawler-detect": "^1.3", "akrabat/ip-address-middleware": "^2.3", "cakephp/chronos": "^3.1", "doctrine/dbal": "^4.2", @@ -28,7 +29,6 @@ "geoip2/geoip2": "^3.0", "guzzlehttp/guzzle": "^7.9", "hidehalo/nanoid-php": "^2.0", - "jaybizzle/crawler-detect": "^1.2.116", "laminas/laminas-config-aggregator": "^1.15", "laminas/laminas-diactoros": "^3.5", "laminas/laminas-inputfilter": "^2.30", From 7434616a8d4dbf462a79d87bd785554b13d58042 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 24 Nov 2024 10:55:55 +0100 Subject: [PATCH 68/80] Update mobiledetect/mobiledetectlib to a commit including PHP 8.4 fixes --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 2515e9f2..36be20b2 100644 --- a/composer.json +++ b/composer.json @@ -39,7 +39,7 @@ "mezzio/mezzio-fastroute": "^3.12", "mezzio/mezzio-problem-details": "^1.15", "mlocati/ip-lib": "^1.18.1", - "mobiledetect/mobiledetectlib": "^4.8", + "mobiledetect/mobiledetectlib": "4.8.x-dev#920c549 as 4.9", "pagerfanta/core": "^3.8", "ramsey/uuid": "^4.7", "shlinkio/doctrine-specification": "^2.1.1", From b2fc19af441180ea4491976fa2b9d61bd08d3d5d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 24 Nov 2024 11:04:14 +0100 Subject: [PATCH 69/80] Replace akrabat/ip-address-middleware with acelaya/ip-address-middleware --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 36be20b2..6b73d903 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,7 @@ "ext-mbstring": "*", "ext-pdo": "*", "acelaya/crawler-detect": "^1.3", - "akrabat/ip-address-middleware": "^2.3", + "acelaya/ip-address-middleware": "^2.4", "cakephp/chronos": "^3.1", "doctrine/dbal": "^4.2", "doctrine/migrations": "^3.8", From fe660654ed2ccabceb43f7dffc97bd93cb91eee5 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 24 Nov 2024 11:04:41 +0100 Subject: [PATCH 70/80] Add PHP 8.4 to the release pipeline --- .github/workflows/publish-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index a81d51fb..443d34a9 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-24.04 strategy: matrix: - php-version: ['8.2', '8.3'] # TODO 8.4 + php-version: ['8.2', '8.3', '8.4'] steps: - uses: actions/checkout@v4 - uses: './.github/actions/ci-setup' From 259aadfdb238be3fa2764da63666611edbba35ad Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 24 Nov 2024 11:05:36 +0100 Subject: [PATCH 71/80] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a810fdc1..f3281630 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ## [Unreleased] ### Added +* [#2159](https://github.com/shlinkio/shlink/issues/2159) Add support for PHP 8.4. * [#2207](https://github.com/shlinkio/shlink/issues/2207) Add `hasRedirectRules` flag to short URL API model. This flag tells if a specific short URL has any redirect rules attached to it. * [#1520](https://github.com/shlinkio/shlink/issues/1520) Allow short URLs list to be filtered by `domain`. From deb9d4bdc74370c0bac09a8b187819e386418fc8 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 24 Nov 2024 11:37:08 +0100 Subject: [PATCH 72/80] Update docker images to Alpine 3.20 --- Dockerfile | 6 +++--- data/infra/ci/install-ms-odbc.sh | 2 +- data/infra/php.Dockerfile | 8 ++++---- data/infra/roadrunner.Dockerfile | 17 +++-------------- 4 files changed, 11 insertions(+), 22 deletions(-) diff --git a/Dockerfile b/Dockerfile index e6e94734..4f3d1ca6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM php:8.3-alpine3.19 as base +FROM php:8.3-alpine3.20 AS base ARG SHLINK_VERSION=latest ENV SHLINK_VERSION ${SHLINK_VERSION} @@ -7,8 +7,8 @@ ENV SHLINK_RUNTIME ${SHLINK_RUNTIME} ENV USER_ID '1001' ENV PDO_SQLSRV_VERSION 5.12.0 -ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486' -ENV MS_ODBC_SQL_VERSION 18_18.1.1.1 +ENV MS_ODBC_DOWNLOAD '7/6/d/76de322a-d860-4894-9945-f0cc5d6a45f8' +ENV MS_ODBC_SQL_VERSION 18_18.4.1.1 ENV LC_ALL 'C' WORKDIR /etc/shlink diff --git a/data/infra/ci/install-ms-odbc.sh b/data/infra/ci/install-ms-odbc.sh index eb3fade1..8e7f931f 100755 --- a/data/infra/ci/install-ms-odbc.sh +++ b/data/infra/ci/install-ms-odbc.sh @@ -3,7 +3,7 @@ set -ex curl https://packages.microsoft.com/keys/microsoft.asc | apt-key add - -curl https://packages.microsoft.com/config/ubuntu/22.04/prod.list > /etc/apt/sources.list.d/mssql-release.list +curl https://packages.microsoft.com/config/ubuntu/24.04/prod.list > /etc/apt/sources.list.d/mssql-release.list apt-get update ACCEPT_EULA=Y apt-get install msodbcsql18 # apt-get install unixodbc-dev diff --git a/data/infra/php.Dockerfile b/data/infra/php.Dockerfile index 4a7904bf..e594664b 100644 --- a/data/infra/php.Dockerfile +++ b/data/infra/php.Dockerfile @@ -1,10 +1,10 @@ -FROM php:8.3-fpm-alpine3.19 +FROM php:8.3-fpm-alpine3.20 MAINTAINER Alejandro Celaya -ENV APCU_VERSION 5.1.23 +ENV APCU_VERSION 5.1.24 ENV PDO_SQLSRV_VERSION 5.12.0 -ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486' -ENV MS_ODBC_SQL_VERSION 18_18.1.1.1 +ENV MS_ODBC_DOWNLOAD '7/6/d/76de322a-d860-4894-9945-f0cc5d6a45f8' +ENV MS_ODBC_SQL_VERSION 18_18.4.1.1 RUN apk update diff --git a/data/infra/roadrunner.Dockerfile b/data/infra/roadrunner.Dockerfile index 0bf251f6..198a6867 100644 --- a/data/infra/roadrunner.Dockerfile +++ b/data/infra/roadrunner.Dockerfile @@ -1,10 +1,9 @@ -FROM php:8.3-alpine3.19 +FROM php:8.3-alpine3.20 MAINTAINER Alejandro Celaya -ENV APCU_VERSION 5.1.23 ENV PDO_SQLSRV_VERSION 5.12.0 -ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486' -ENV MS_ODBC_SQL_VERSION 18_18.1.1.1 +ENV MS_ODBC_DOWNLOAD '7/6/d/76de322a-d860-4894-9945-f0cc5d6a45f8' +ENV MS_ODBC_SQL_VERSION 18_18.4.1.1 RUN apk update @@ -36,16 +35,6 @@ RUN apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS linux-headers && \ apk del .phpize-deps RUN docker-php-ext-install bcmath -# Install APCu extension -ADD https://pecl.php.net/get/apcu-$APCU_VERSION.tgz /tmp/apcu.tar.gz -RUN mkdir -p /usr/src/php/ext/apcu \ - && tar xf /tmp/apcu.tar.gz -C /usr/src/php/ext/apcu --strip-components=1 \ - && docker-php-ext-configure apcu \ - && docker-php-ext-install apcu \ - && rm /tmp/apcu.tar.gz \ - && rm /usr/local/etc/php/conf.d/docker-php-ext-apcu.ini \ - && echo extension=apcu.so > /usr/local/etc/php/conf.d/20-php-ext-apcu.ini - # Install xdebug and sqlsrv driver RUN apk add --update linux-headers && \ wget https://download.microsoft.com/download/${MS_ODBC_DOWNLOAD}/msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \ From 8274525f754bac418db9419e1e504c79da42ee14 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 24 Nov 2024 12:53:49 +0100 Subject: [PATCH 73/80] Add redirect_url field to track where a visitor is redirected for a visit --- docs/async-api/async-api.json | 5 +++ docs/swagger/definitions/Visit.json | 4 ++ ...hlinkio.Shlink.Core.Visit.Entity.Visit.php | 6 +++ .../Core/migrations/Version20241124112257.php | 39 +++++++++++++++++++ module/Core/src/Visit/Entity/Visit.php | 3 ++ module/Core/src/Visit/Model/Visitor.php | 7 ++++ .../PublishingUpdatesGeneratorTest.php | 2 + module/Core/test/Visit/Entity/VisitTest.php | 4 ++ .../Rest/test-api/Action/OrphanVisitsTest.php | 3 ++ 9 files changed, 73 insertions(+) create mode 100644 module/Core/migrations/Version20241124112257.php diff --git a/docs/async-api/async-api.json b/docs/async-api/async-api.json index b2da154b..09817a99 100644 --- a/docs/async-api/async-api.json +++ b/docs/async-api/async-api.json @@ -247,6 +247,11 @@ "type": "string", "nullable": true, "description": "The originally visited URL that triggered the tracking of this visit" + }, + "redirectUrl": { + "type": "string", + "nullable": true, + "description": "The URL to which the visitor was redirected" } }, "example": { diff --git a/docs/swagger/definitions/Visit.json b/docs/swagger/definitions/Visit.json index c4589bb1..826ad1ac 100644 --- a/docs/swagger/definitions/Visit.json +++ b/docs/swagger/definitions/Visit.json @@ -25,6 +25,10 @@ "visitedUrl": { "type": ["string", "null"], "description": "The originally visited URL that triggered the tracking of this visit" + }, + "redirectUrl": { + "type": ["string", "null"], + "description": "The URL to which the visitor was redirected" } } } diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Visit.Entity.Visit.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Visit.Entity.Visit.php index 7d402384..34d98572 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Visit.Entity.Visit.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Visit.Entity.Visit.php @@ -75,4 +75,10 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->columnName('potential_bot') ->option('default', false) ->build(); + + fieldWithUtf8Charset($builder->createField('redirectUrl', Types::STRING), $emConfig) + ->columnName('redirect_url') + ->length(Visitor::REDIRECT_URL_MAX_LENGTH) + ->nullable() + ->build(); }; diff --git a/module/Core/migrations/Version20241124112257.php b/module/Core/migrations/Version20241124112257.php new file mode 100644 index 00000000..49c5eb05 --- /dev/null +++ b/module/Core/migrations/Version20241124112257.php @@ -0,0 +1,39 @@ +getTable('visits'); + $this->skipIf($visits->hasColumn(self::COLUMN_NAME)); + + $visits->addColumn('redirected_url', Types::STRING, [ + 'length' => 2048, + 'notnull' => false, + 'default' => null, + ]); + } + + public function down(Schema $schema): void + { + $visits = $schema->getTable('visits'); + $this->skipIf(! $visits->hasColumn(self::COLUMN_NAME)); + $visits->dropColumn(self::COLUMN_NAME); + } + + public function isTransactional(): bool + { + return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); + } +} diff --git a/module/Core/src/Visit/Entity/Visit.php b/module/Core/src/Visit/Entity/Visit.php index 9e8540bc..033d451b 100644 --- a/module/Core/src/Visit/Entity/Visit.php +++ b/module/Core/src/Visit/Entity/Visit.php @@ -28,6 +28,7 @@ class Visit extends AbstractEntity implements JsonSerializable public readonly bool $potentialBot, public readonly string|null $remoteAddr = null, public readonly string|null $visitedUrl = null, + public readonly string|null $redirectUrl = null, private VisitLocation|null $visitLocation = null, public readonly Chronos $date = new Chronos(), ) { @@ -68,6 +69,7 @@ class Visit extends AbstractEntity implements JsonSerializable potentialBot: $visitor->potentialBot, remoteAddr: self::processAddress($visitor->remoteAddress, $anonymize), visitedUrl: $visitor->visitedUrl, + redirectUrl: null, // TODO visitLocation: $geolocation !== null ? VisitLocation::fromGeolocation($geolocation) : null, ); } @@ -156,6 +158,7 @@ class Visit extends AbstractEntity implements JsonSerializable 'visitLocation' => $this->visitLocation, 'potentialBot' => $this->potentialBot, 'visitedUrl' => $this->visitedUrl, + 'redirectUrl' => $this->redirectUrl, ]; if (! $this->isOrphan()) { return $base; diff --git a/module/Core/src/Visit/Model/Visitor.php b/module/Core/src/Visit/Model/Visitor.php index e13712e1..b33d10a1 100644 --- a/module/Core/src/Visit/Model/Visitor.php +++ b/module/Core/src/Visit/Model/Visitor.php @@ -19,6 +19,7 @@ final readonly class Visitor public const REFERER_MAX_LENGTH = 1024; public const REMOTE_ADDRESS_MAX_LENGTH = 256; public const VISITED_URL_MAX_LENGTH = 2048; + public const REDIRECT_URL_MAX_LENGTH = 2048; private function __construct( public string $userAgent, @@ -27,6 +28,7 @@ final readonly class Visitor public string $visitedUrl, public bool $potentialBot, public Location|null $geolocation, + public string $redirectUrl, ) { } @@ -36,6 +38,7 @@ final readonly class Visitor string|null $remoteAddress = null, string $visitedUrl = '', Location|null $geolocation = null, + string $redirectUrl = '', ): self { return new self( userAgent: self::cropToLength($userAgent, self::USER_AGENT_MAX_LENGTH), @@ -46,6 +49,7 @@ final readonly class Visitor visitedUrl: self::cropToLength($visitedUrl, self::VISITED_URL_MAX_LENGTH), potentialBot: isCrawler($userAgent), geolocation: $geolocation, + redirectUrl: self::cropToLength($redirectUrl, self::REDIRECT_URL_MAX_LENGTH), ); } @@ -62,6 +66,8 @@ final readonly class Visitor remoteAddress: ipAddressFromRequest($request), visitedUrl: $request->getUri()->__toString(), geolocation: geolocationFromRequest($request), + // TODO + redirectUrl: '', ); } @@ -85,6 +91,7 @@ final readonly class Visitor // Keep the fact that the visit was a potential bot, even if we no longer save the user agent potentialBot: $this->potentialBot, geolocation: $this->geolocation, + redirectUrl: $this->redirectUrl, ); } } diff --git a/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php b/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php index 2e232038..310c8b3f 100644 --- a/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php +++ b/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php @@ -80,6 +80,7 @@ class PublishingUpdatesGeneratorTest extends TestCase 'date' => $visit->date->toAtomString(), 'potentialBot' => false, 'visitedUrl' => '', + 'redirectUrl' => null, ], ], $update->payload); } @@ -105,6 +106,7 @@ class PublishingUpdatesGeneratorTest extends TestCase 'potentialBot' => false, 'visitedUrl' => $orphanVisit->visitedUrl, 'type' => $orphanVisit->type->value, + 'redirectUrl' => null, ], ], $update->payload); } diff --git a/module/Core/test/Visit/Entity/VisitTest.php b/module/Core/test/Visit/Entity/VisitTest.php index db23af97..438ca55f 100644 --- a/module/Core/test/Visit/Entity/VisitTest.php +++ b/module/Core/test/Visit/Entity/VisitTest.php @@ -34,6 +34,7 @@ class VisitTest extends TestCase 'visitLocation' => null, 'potentialBot' => $expectedToBePotentialBot, 'visitedUrl' => $visit->visitedUrl, + 'redirectUrl' => $visit->redirectUrl, ], $visit->jsonSerialize()); } @@ -67,6 +68,7 @@ class VisitTest extends TestCase 'potentialBot' => false, 'visitedUrl' => '', 'type' => VisitType::BASE_URL->value, + 'redirectUrl' => null, ], ]; yield 'invalid short url visit' => [ @@ -83,6 +85,7 @@ class VisitTest extends TestCase 'potentialBot' => false, 'visitedUrl' => 'https://example.com/foo', 'type' => VisitType::INVALID_SHORT_URL->value, + 'redirectUrl' => null, ], ]; yield 'regular 404 visit' => [ @@ -101,6 +104,7 @@ class VisitTest extends TestCase 'potentialBot' => false, 'visitedUrl' => 'https://s.test/foo/bar', 'type' => VisitType::REGULAR_404->value, + 'redirectUrl' => null, ], ]; } diff --git a/module/Rest/test-api/Action/OrphanVisitsTest.php b/module/Rest/test-api/Action/OrphanVisitsTest.php index cf7cee0f..3761e113 100644 --- a/module/Rest/test-api/Action/OrphanVisitsTest.php +++ b/module/Rest/test-api/Action/OrphanVisitsTest.php @@ -21,6 +21,7 @@ class OrphanVisitsTest extends ApiTestCase 'potentialBot' => true, 'visitedUrl' => 'foo.com', 'type' => 'invalid_short_url', + 'redirectUrl' => null, ]; private const REGULAR_NOT_FOUND = [ 'referer' => 'https://s.test/foo/bar', @@ -30,6 +31,7 @@ class OrphanVisitsTest extends ApiTestCase 'potentialBot' => false, 'visitedUrl' => '', 'type' => 'regular_404', + 'redirectUrl' => null, ]; private const BASE_URL = [ 'referer' => 'https://s.test', @@ -39,6 +41,7 @@ class OrphanVisitsTest extends ApiTestCase 'potentialBot' => false, 'visitedUrl' => '', 'type' => 'base_url', + 'redirectUrl' => null, ]; #[Test, DataProvider('provideQueries')] From 89f70114e4304021597a7fa32da9b7a93e7e09c4 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 24 Nov 2024 13:18:32 +0100 Subject: [PATCH 74/80] Fix typo in migration --- module/Core/migrations/Version20241124112257.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/Core/migrations/Version20241124112257.php b/module/Core/migrations/Version20241124112257.php index 49c5eb05..c11cbe2b 100644 --- a/module/Core/migrations/Version20241124112257.php +++ b/module/Core/migrations/Version20241124112257.php @@ -18,7 +18,7 @@ final class Version20241124112257 extends AbstractMigration $visits = $schema->getTable('visits'); $this->skipIf($visits->hasColumn(self::COLUMN_NAME)); - $visits->addColumn('redirected_url', Types::STRING, [ + $visits->addColumn(self::COLUMN_NAME, Types::STRING, [ 'length' => 2048, 'notnull' => false, 'default' => null, From 86cc2b717c32ab0b8355030e9aa3cea96038b496 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 24 Nov 2024 13:21:48 +0100 Subject: [PATCH 75/80] Save where a visitor is redirected for any kind of tracked visit --- config/constants.php | 1 + module/Core/src/Action/AbstractTrackingAction.php | 10 ++++++++-- .../src/ErrorHandler/NotFoundTrackerMiddleware.php | 13 ++++++++++--- .../Middleware/ExtraPathRedirectMiddleware.php | 9 +++++++-- module/Core/src/Visit/Entity/Visit.php | 2 +- module/Core/src/Visit/Model/Visitor.php | 10 +++++----- 6 files changed, 32 insertions(+), 13 deletions(-) diff --git a/config/constants.php b/config/constants.php index d6bb9621..09df0e60 100644 --- a/config/constants.php +++ b/config/constants.php @@ -22,3 +22,4 @@ const DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS = true; const DEFAULT_QR_CODE_COLOR = '#000000'; // Black const DEFAULT_QR_CODE_BG_COLOR = '#ffffff'; // White const IP_ADDRESS_REQUEST_ATTRIBUTE = 'remote_address'; +const REDIRECT_URL_REQUEST_ATTRIBUTE = 'redirect_url'; diff --git a/module/Core/src/Action/AbstractTrackingAction.php b/module/Core/src/Action/AbstractTrackingAction.php index 78eebc05..ff35828f 100644 --- a/module/Core/src/Action/AbstractTrackingAction.php +++ b/module/Core/src/Action/AbstractTrackingAction.php @@ -16,6 +16,8 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface; +use const Shlinkio\Shlink\REDIRECT_URL_REQUEST_ATTRIBUTE; + abstract class AbstractTrackingAction implements MiddlewareInterface, RequestMethodInterface { public function __construct( @@ -30,9 +32,13 @@ abstract class AbstractTrackingAction implements MiddlewareInterface, RequestMet try { $shortUrl = $this->urlResolver->resolveEnabledShortUrl($identifier); - $this->requestTracker->trackIfApplicable($shortUrl, $request); + $response = $this->createSuccessResp($shortUrl, $request); + $this->requestTracker->trackIfApplicable($shortUrl, $request->withAttribute( + REDIRECT_URL_REQUEST_ATTRIBUTE, + $response->hasHeader('Location') ? $response->getHeaderLine('Location') : null, + )); - return $this->createSuccessResp($shortUrl, $request); + return $response; } catch (ShortUrlNotFoundException) { return $this->createErrorResp($request, $handler); } diff --git a/module/Core/src/ErrorHandler/NotFoundTrackerMiddleware.php b/module/Core/src/ErrorHandler/NotFoundTrackerMiddleware.php index f3342c5a..633d83db 100644 --- a/module/Core/src/ErrorHandler/NotFoundTrackerMiddleware.php +++ b/module/Core/src/ErrorHandler/NotFoundTrackerMiddleware.php @@ -10,7 +10,9 @@ use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface; -class NotFoundTrackerMiddleware implements MiddlewareInterface +use const Shlinkio\Shlink\REDIRECT_URL_REQUEST_ATTRIBUTE; + +readonly class NotFoundTrackerMiddleware implements MiddlewareInterface { public function __construct(private RequestTrackerInterface $requestTracker) { @@ -18,7 +20,12 @@ class NotFoundTrackerMiddleware implements MiddlewareInterface public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { - $this->requestTracker->trackNotFoundIfApplicable($request); - return $handler->handle($request); + $response = $handler->handle($request); + $this->requestTracker->trackNotFoundIfApplicable($request->withAttribute( + REDIRECT_URL_REQUEST_ATTRIBUTE, + $response->hasHeader('Location') ? $response->getHeaderLine('Location') : null, + )); + + return $response; } } diff --git a/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php b/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php index 4a02f6e9..4b013b33 100644 --- a/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php +++ b/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php @@ -25,6 +25,8 @@ use function implode; use function sprintf; use function trim; +use const Shlinkio\Shlink\REDIRECT_URL_REQUEST_ATTRIBUTE; + readonly class ExtraPathRedirectMiddleware implements MiddlewareInterface { public function __construct( @@ -73,9 +75,12 @@ readonly class ExtraPathRedirectMiddleware implements MiddlewareInterface try { $shortUrl = $this->resolver->resolveEnabledShortUrl($identifier); - $this->requestTracker->trackIfApplicable($shortUrl, $request); - $longUrl = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, $request, $extraPath); + $this->requestTracker->trackIfApplicable( + $shortUrl, + $request->withAttribute(REDIRECT_URL_REQUEST_ATTRIBUTE, $longUrl), + ); + return $this->redirectResponseHelper->buildRedirectResponse($longUrl); } catch (ShortUrlNotFoundException) { if ($extraPath === null || ! $this->urlShortenerOptions->multiSegmentSlugsEnabled) { diff --git a/module/Core/src/Visit/Entity/Visit.php b/module/Core/src/Visit/Entity/Visit.php index 033d451b..70733593 100644 --- a/module/Core/src/Visit/Entity/Visit.php +++ b/module/Core/src/Visit/Entity/Visit.php @@ -69,7 +69,7 @@ class Visit extends AbstractEntity implements JsonSerializable potentialBot: $visitor->potentialBot, remoteAddr: self::processAddress($visitor->remoteAddress, $anonymize), visitedUrl: $visitor->visitedUrl, - redirectUrl: null, // TODO + redirectUrl: $visitor->redirectUrl, visitLocation: $geolocation !== null ? VisitLocation::fromGeolocation($geolocation) : null, ); } diff --git a/module/Core/src/Visit/Model/Visitor.php b/module/Core/src/Visit/Model/Visitor.php index b33d10a1..53504d75 100644 --- a/module/Core/src/Visit/Model/Visitor.php +++ b/module/Core/src/Visit/Model/Visitor.php @@ -12,6 +12,7 @@ use function Shlinkio\Shlink\Core\geolocationFromRequest; use function Shlinkio\Shlink\Core\ipAddressFromRequest; use function Shlinkio\Shlink\Core\isCrawler; use function substr; +use const Shlinkio\Shlink\REDIRECT_URL_REQUEST_ATTRIBUTE; final readonly class Visitor { @@ -28,7 +29,7 @@ final readonly class Visitor public string $visitedUrl, public bool $potentialBot, public Location|null $geolocation, - public string $redirectUrl, + public string|null $redirectUrl, ) { } @@ -38,7 +39,7 @@ final readonly class Visitor string|null $remoteAddress = null, string $visitedUrl = '', Location|null $geolocation = null, - string $redirectUrl = '', + string|null $redirectUrl = null, ): self { return new self( userAgent: self::cropToLength($userAgent, self::USER_AGENT_MAX_LENGTH), @@ -49,7 +50,7 @@ final readonly class Visitor visitedUrl: self::cropToLength($visitedUrl, self::VISITED_URL_MAX_LENGTH), potentialBot: isCrawler($userAgent), geolocation: $geolocation, - redirectUrl: self::cropToLength($redirectUrl, self::REDIRECT_URL_MAX_LENGTH), + redirectUrl: $redirectUrl === null ? null : self::cropToLength($redirectUrl, self::REDIRECT_URL_MAX_LENGTH), ); } @@ -66,8 +67,7 @@ final readonly class Visitor remoteAddress: ipAddressFromRequest($request), visitedUrl: $request->getUri()->__toString(), geolocation: geolocationFromRequest($request), - // TODO - redirectUrl: '', + redirectUrl: $request->getAttribute(REDIRECT_URL_REQUEST_ATTRIBUTE), ); } From 85065c9330d3ceab399af7a44d8e6ce8a2c77d64 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 24 Nov 2024 14:05:33 +0100 Subject: [PATCH 76/80] Test behavior to track redirect URL --- module/Core/src/Visit/Model/Visitor.php | 1 + module/Core/test/Action/PixelActionTest.php | 13 +++++++--- .../Core/test/Action/RedirectActionTest.php | 12 +++++++--- .../NotFoundTrackerMiddlewareTest.php | 24 +++++++++++++++---- .../ExtraPathRedirectMiddlewareTest.php | 7 +++++- 5 files changed, 45 insertions(+), 12 deletions(-) diff --git a/module/Core/src/Visit/Model/Visitor.php b/module/Core/src/Visit/Model/Visitor.php index 53504d75..cab834e6 100644 --- a/module/Core/src/Visit/Model/Visitor.php +++ b/module/Core/src/Visit/Model/Visitor.php @@ -12,6 +12,7 @@ use function Shlinkio\Shlink\Core\geolocationFromRequest; use function Shlinkio\Shlink\Core\ipAddressFromRequest; use function Shlinkio\Shlink\Core\isCrawler; use function substr; + use const Shlinkio\Shlink\REDIRECT_URL_REQUEST_ATTRIBUTE; final readonly class Visitor diff --git a/module/Core/test/Action/PixelActionTest.php b/module/Core/test/Action/PixelActionTest.php index d6f2566a..e78df177 100644 --- a/module/Core/test/Action/PixelActionTest.php +++ b/module/Core/test/Action/PixelActionTest.php @@ -16,6 +16,8 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface; +use const Shlinkio\Shlink\REDIRECT_URL_REQUEST_ATTRIBUTE; + class PixelActionTest extends TestCase { private PixelAction $action; @@ -34,12 +36,17 @@ class PixelActionTest extends TestCase public function imageIsReturned(): void { $shortCode = 'abc123'; + $shortUrl = ShortUrl::withLongUrl('http://domain.com/foo/bar'); + $request = (new ServerRequest())->withAttribute('shortCode', $shortCode); + $this->urlResolver->expects($this->once())->method('resolveEnabledShortUrl')->with( ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, ''), - )->willReturn(ShortUrl::withLongUrl('http://domain.com/foo/bar')); - $this->requestTracker->expects($this->once())->method('trackIfApplicable')->withAnyParameters(); + )->willReturn($shortUrl); + $this->requestTracker->expects($this->once())->method('trackIfApplicable')->with( + $shortUrl, + $request->withAttribute(REDIRECT_URL_REQUEST_ATTRIBUTE, null), + ); - $request = (new ServerRequest())->withAttribute('shortCode', $shortCode); $response = $this->action->process($request, $this->createMock(RequestHandlerInterface::class)); self::assertInstanceOf(PixelResponse::class, $response); diff --git a/module/Core/test/Action/RedirectActionTest.php b/module/Core/test/Action/RedirectActionTest.php index 2364371c..fa4a561d 100644 --- a/module/Core/test/Action/RedirectActionTest.php +++ b/module/Core/test/Action/RedirectActionTest.php @@ -19,6 +19,8 @@ use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface; use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface; +use const Shlinkio\Shlink\REDIRECT_URL_REQUEST_ATTRIBUTE; + class RedirectActionTest extends TestCase { private const LONG_URL = 'https://domain.com/foo/bar?some=thing'; @@ -50,16 +52,20 @@ class RedirectActionTest extends TestCase { $shortCode = 'abc123'; $shortUrl = ShortUrl::withLongUrl(self::LONG_URL); + $expectedResp = new Response\RedirectResponse(self::LONG_URL); + $request = (new ServerRequest())->withAttribute('shortCode', $shortCode); + $this->urlResolver->expects($this->once())->method('resolveEnabledShortUrl')->with( ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, ''), )->willReturn($shortUrl); - $this->requestTracker->expects($this->once())->method('trackIfApplicable'); - $expectedResp = new Response\RedirectResponse(self::LONG_URL); + $this->requestTracker->expects($this->once())->method('trackIfApplicable')->with( + $shortUrl, + $request->withAttribute(REDIRECT_URL_REQUEST_ATTRIBUTE, self::LONG_URL), + ); $this->redirectRespHelper->expects($this->once())->method('buildRedirectResponse')->with( self::LONG_URL, )->willReturn($expectedResp); - $request = (new ServerRequest())->withAttribute('shortCode', $shortCode); $response = $this->action->process($request, $this->createMock(RequestHandlerInterface::class)); self::assertSame($expectedResp, $response); diff --git a/module/Core/test/ErrorHandler/NotFoundTrackerMiddlewareTest.php b/module/Core/test/ErrorHandler/NotFoundTrackerMiddlewareTest.php index 4558197b..9df12a6d 100644 --- a/module/Core/test/ErrorHandler/NotFoundTrackerMiddlewareTest.php +++ b/module/Core/test/ErrorHandler/NotFoundTrackerMiddlewareTest.php @@ -4,7 +4,9 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\ErrorHandler; +use Laminas\Diactoros\Response; use Laminas\Diactoros\ServerRequestFactory; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -14,6 +16,8 @@ use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType; use Shlinkio\Shlink\Core\ErrorHandler\NotFoundTrackerMiddleware; use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface; +use const Shlinkio\Shlink\REDIRECT_URL_REQUEST_ATTRIBUTE; + class NotFoundTrackerMiddlewareTest extends TestCase { private NotFoundTrackerMiddleware $middleware; @@ -33,12 +37,22 @@ class NotFoundTrackerMiddlewareTest extends TestCase ); } - #[Test] - public function delegatesIntoRequestTracker(): void + #[Test, DataProvider('provideResponses')] + public function delegatesIntoRequestTracker(Response $resp, string|null $expectedRedirectUrl): void { - $this->handler->expects($this->once())->method('handle')->with($this->request); - $this->requestTracker->expects($this->once())->method('trackNotFoundIfApplicable')->with($this->request); + $this->handler->expects($this->once())->method('handle')->with($this->request)->willReturn($resp); + $this->requestTracker->expects($this->once())->method('trackNotFoundIfApplicable')->with( + $this->request->withAttribute(REDIRECT_URL_REQUEST_ATTRIBUTE, $expectedRedirectUrl), + ); - $this->middleware->process($this->request, $this->handler); + $result = $this->middleware->process($this->request, $this->handler); + + self::assertSame($resp, $result); + } + + public static function provideResponses(): iterable + { + yield 'no location response' => [new Response(), null]; + yield 'location response' => [new Response\RedirectResponse('the_location'), 'the_location']; } } diff --git a/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php b/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php index 85168020..84ceb790 100644 --- a/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php +++ b/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php @@ -30,6 +30,8 @@ use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface; use function Laminas\Stratigility\middleware; use function str_starts_with; +use const Shlinkio\Shlink\REDIRECT_URL_REQUEST_ATTRIBUTE; + class ExtraPathRedirectMiddlewareTest extends TestCase { private MockObject & ShortUrlResolverInterface $resolver; @@ -159,7 +161,10 @@ class ExtraPathRedirectMiddlewareTest extends TestCase $this->redirectResponseHelper->expects($this->once())->method('buildRedirectResponse')->with( 'the_built_long_url', )->willReturn(new RedirectResponse('')); - $this->requestTracker->expects($this->once())->method('trackIfApplicable')->with($shortUrl, $request); + $this->requestTracker->expects($this->once())->method('trackIfApplicable')->with( + $shortUrl, + $request->withAttribute(REDIRECT_URL_REQUEST_ATTRIBUTE, 'the_built_long_url'), + ); $this->middleware($options)->process($request, $this->handler); } From d5544554efc7645761f390560ca4ba61ad79df53 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 24 Nov 2024 14:08:23 +0100 Subject: [PATCH 77/80] Improve API docs description for redirectUrl fields --- docs/async-api/async-api.json | 2 +- docs/swagger/definitions/Visit.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/async-api/async-api.json b/docs/async-api/async-api.json index 09817a99..2d69084b 100644 --- a/docs/async-api/async-api.json +++ b/docs/async-api/async-api.json @@ -251,7 +251,7 @@ "redirectUrl": { "type": "string", "nullable": true, - "description": "The URL to which the visitor was redirected" + "description": "The URL to which the visitor was redirected, or null if a redirect did not occur, like for 404 requests or pixel tracking" } }, "example": { diff --git a/docs/swagger/definitions/Visit.json b/docs/swagger/definitions/Visit.json index 826ad1ac..2ccdfd23 100644 --- a/docs/swagger/definitions/Visit.json +++ b/docs/swagger/definitions/Visit.json @@ -28,7 +28,7 @@ }, "redirectUrl": { "type": ["string", "null"], - "description": "The URL to which the visitor was redirected" + "description": "The URL to which the visitor was redirected, or null if a redirect did not occur, like for 404 requests or pixel tracking" } } } From 571a4643ab139f03c9e35e188da3ee66650eafae Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 24 Nov 2024 14:11:44 +0100 Subject: [PATCH 78/80] Update changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3281630..fee93cf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * `geolocation-country-code`: Allows to perform redirections based on the ISO 3166-1 alpha-2 two-letter country code resolved while geolocating the visitor. * `geolocation-city-name`: Allows to perform redirections based on the city name resolved while geolocating the visitor. +* [#2032](https://github.com/shlinkio/shlink/issues/2032) Save the URL to which a visitor is redirected when a visit is tracked. + + The value is exposed in the API as a new `redirectUrl` field for visit objects. + + This is useful to know where a visitor was redirected for a short URL with dynamic redirect rules, for special redirects, or simply in case the long URL was changed over time, and you still want to know where visitors were redirected originally. + + Some visits may not have a redirect URL if a redirect didn't happen, like for orphan visits when no special redirects are configured, or when a visit is tracked as part of the pixel action. + ### Changed * [#2193](https://github.com/shlinkio/shlink/issues/2193) API keys are now hashed using SHA256, instead of being saved in plain text. From 6a96b72b942dab23a4f974077d8571dcd285848b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 24 Nov 2024 14:23:12 +0100 Subject: [PATCH 79/80] Add real version constraints for Shlink packages --- composer.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.json b/composer.json index 6b73d903..6656c8ea 100644 --- a/composer.json +++ b/composer.json @@ -43,12 +43,12 @@ "pagerfanta/core": "^3.8", "ramsey/uuid": "^4.7", "shlinkio/doctrine-specification": "^2.1.1", - "shlinkio/shlink-common": "dev-main#abdad29 as 6.6", - "shlinkio/shlink-config": "dev-main#e7dbed3 as 3.4", + "shlinkio/shlink-common": "^6.6", + "shlinkio/shlink-config": "^3.4", "shlinkio/shlink-event-dispatcher": "^4.1", "shlinkio/shlink-importer": "^5.3.2", - "shlinkio/shlink-installer": "dev-develop#b7503ad as 9.3", - "shlinkio/shlink-ip-geolocation": "dev-main#fadae5d as 4.2", + "shlinkio/shlink-installer": "^9.3", + "shlinkio/shlink-ip-geolocation": "^4.2", "shlinkio/shlink-json": "^1.1", "spiral/roadrunner": "^2024.1", "spiral/roadrunner-cli": "^2.6", From 19f56e7ab03a03c0323fe4abef0497ff41135927 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 24 Nov 2024 14:26:09 +0100 Subject: [PATCH 80/80] Add v4.3.0 to changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fee93cf1..be405837 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] +## [4.3.0] - 2024-11-24 ### Added * [#2159](https://github.com/shlinkio/shlink/issues/2159) Add support for PHP 8.4. * [#2207](https://github.com/shlinkio/shlink/issues/2207) Add `hasRedirectRules` flag to short URL API model. This flag tells if a specific short URL has any redirect rules attached to it.