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 83c424ea9..b2da154b1 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 1535b65f8..5de6f3846 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 b159da133..2277b0e5f 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 @@ ->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 e394fb5ac..6f4b59c6d 100644 --- a/module/Core/src/ShortUrl/Entity/ShortUrl.php +++ b/module/Core/src/ShortUrl/Entity/ShortUrl.php @@ -12,6 +12,7 @@ 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 @@ private function __construct( private bool $forwardQuery = true, private ?string $importSource = null, private ?string $importOriginalShortCode = null, + private Collection $redirectRules = new ArrayCollection(), ) { } @@ -283,6 +286,7 @@ public function toArray(?VisitsSummary $precalculatedSummary = null): array 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 6f4b59c6d..35c0dfd22 100644 --- a/module/Core/src/ShortUrl/Entity/ShortUrl.php +++ b/module/Core/src/ShortUrl/Entity/ShortUrl.php @@ -264,7 +264,13 @@ public function isEnabled(): bool 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 @@ public function toArray(?VisitsSummary $precalculatedSummary = null): array '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 50efaaee6..d5c34b8b2 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlWithVisitsSummary.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlWithVisitsSummary.php @@ -9,19 +9,26 @@ 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 @@ public static function fromShortUrl(ShortUrl $shortUrl): self 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 e8fd4ac64..67d85b776 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php @@ -43,7 +43,7 @@ public function findList(ShortUrlsListFiltering $filtering): array $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 @@ private function createListQueryBuilder(ShortUrlsCountFiltering $filtering): Que { $qb = $this->getEntityManager()->createQueryBuilder(); $qb->from(ShortUrl::class, 's') + ->leftJoin('s.domain', 'd') ->where('1=1'); $dateRange = $filtering->dateRange; @@ -129,8 +130,7 @@ private function createListQueryBuilder(ShortUrlsCountFiltering $filtering): Que $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 1ea76bf67..08b4cdb72 100644 --- a/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php +++ b/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php @@ -71,6 +71,7 @@ public function visitIsProperlySerializedIntoUpdate(string $method, string $expe 'crawlable' => false, 'forwardQuery' => true, 'visitsSummary' => VisitsSummary::fromTotalAndNonBots(0, 0), + 'hasRedirectRules' => false, ], 'visit' => [ 'referer' => '', @@ -145,6 +146,7 @@ public function shortUrlIsProperlySerializedIntoUpdate(): void '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 e3fc49a66..a17e51614 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 35c0dfd22..ac50064c1 100644 --- a/module/Core/src/ShortUrl/Entity/ShortUrl.php +++ b/module/Core/src/ShortUrl/Entity/ShortUrl.php @@ -265,7 +265,7 @@ public function isEnabled(): bool } /** - * @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 67d85b776..8c49697a2 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php @@ -56,7 +56,7 @@ public function findList(ShortUrlsListFiltering $filtering): array $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 7d172ff4d..89bdaaf4a 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 1136aca10..17b6f97f9 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 c1a6eafcd..11c1e0a79 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 e7500a9ac..380c65260 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 6d38ff0ff..9fd39d445 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\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 @@ private function resolveColumnsMap(InputInterface $input): array } 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 c2e966115..b07975be0 100644 --- a/module/CLI/test-cli/Command/CreateShortUrlTest.php +++ b/module/CLI/test-cli/Command/CreateShortUrlTest.php @@ -8,6 +8,7 @@ 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 @@ public function defaultDomainIsIgnoredWhenExplicitlyProvided(): void 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 1ed15d7cc..40e00cc0b 100644 --- a/module/CLI/test-cli/Command/ImportShortUrlsTest.php +++ b/module/CLI/test-cli/Command/ImportShortUrlsTest.php @@ -6,6 +6,7 @@ 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 @@ public function defaultDomainIsIgnoredWhenExplicitlyProvided(): void [$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 b3d2b7340..ba3446a76 100644 --- a/module/Core/src/Domain/Entity/Domain.php +++ b/module/Core/src/Domain/Entity/Domain.php @@ -11,6 +11,8 @@ 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 906adc63d..15b9d47f5 100644 --- a/module/Core/src/ShortUrl/Persistence/ShortUrlsCountFiltering.php +++ b/module/Core/src/ShortUrl/Persistence/ShortUrlsCountFiltering.php @@ -25,6 +25,7 @@ public function __construct( 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 8c49697a2..70e9dbffc 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php @@ -104,14 +104,13 @@ private function createListQueryBuilder(ShortUrlsCountFiltering $filtering): Que $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 @@ private function createListQueryBuilder(ShortUrlsCountFiltering $filtering): Que : $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 0708a4e18..1df109b34 100644 --- a/module/Core/src/Visit/Repository/VisitRepository.php +++ b/module/Core/src/Visit/Repository/VisitRepository.php @@ -8,6 +8,7 @@ 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 @@ private function createVisitsByDomainQueryBuilder(string $domain, VisitsCountFil $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 7f3e22822..0952670be 100644 --- a/module/Core/src/Visit/VisitsStatsHelper.php +++ b/module/Core/src/Visit/VisitsStatsHelper.php @@ -109,7 +109,7 @@ public function visitsForDomain(string $domain, VisitsParams $params, ?ApiKey $a { /** @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 9dc18390f..8d7579b7f 100644 --- a/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php +++ b/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php @@ -227,7 +227,7 @@ public function findVisitsByDomainReturnsProperData(): void $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 @@ public function findVisitsByDomainReturnsProperData(): void 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 @@ public function countVisitsByDomainReturnsProperData(): void $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 @@ public function countVisitsByDomainReturnsProperData(): void 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 c1aa07475..61fb12932 100644 --- a/module/Core/test/Visit/VisitsStatsHelperTest.php +++ b/module/Core/test/Visit/VisitsStatsHelperTest.php @@ -240,11 +240,11 @@ public function visitsForDefaultDomainAreReturnedAsExpected(?ApiKey $apiKey): vo ); $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 @@ public function visitsForDefaultDomainAreReturnedAsExpected(?ApiKey $apiKey): vo [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 4d5342028..fc9cf20c5 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\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 @@ private function resolveDomainParam(Request $request): string { $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 3a06257bb..628b7211c 100644 --- a/module/Rest/test-api/Action/DomainVisitsTest.php +++ b/module/Rest/test-api/Action/DomainVisitsTest.php @@ -7,6 +7,7 @@ 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 @@ public function expectedVisitsAreReturned( 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 d60dae2eb..d4a16573e 100644 --- a/module/Rest/test/Action/Visit/DomainVisitsActionTest.php +++ b/module/Rest/test/Action/Visit/DomainVisitsActionTest.php @@ -12,6 +12,7 @@ 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 @@ public function providingCorrectDomainReturnsVisits(string $providedDomain, stri 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 | 70 ++++++++++++++----- .../test/ShortUrl/ShortUrlListServiceTest.php | 2 +- 9 files changed, 84 insertions(+), 26 deletions(-) diff --git a/docs/swagger/paths/v1_short-urls.json b/docs/swagger/paths/v1_short-urls.json index 89bdaaf4a..6ca05c2ef 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 176800abe..c1a3ab237 100644 --- a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php @@ -74,7 +74,7 @@ public function havingMorePagesButAnsweringNoCallsListJustOnce(): void } $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 @@ public function provideOptionalFlagsMakesNewColumnsToBeIncluded( 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 88e20aa77..e625087ef 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 @@ public static function fromRawData(array $query): self 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 0a0d45ed6..600ebc339 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 @@ private function initialize(): void $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 15b9d47f5..b27fe7c56 100644 --- a/module/Core/src/ShortUrl/Persistence/ShortUrlsCountFiltering.php +++ b/module/Core/src/ShortUrl/Persistence/ShortUrlsCountFiltering.php @@ -44,6 +44,7 @@ public static function fromParams(ShortUrlsParams $params, ?ApiKey $apiKey, stri $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 589947dd1..b3946ab1b 100644 --- a/module/Core/src/ShortUrl/Persistence/ShortUrlsListFiltering.php +++ b/module/Core/src/ShortUrl/Persistence/ShortUrlsListFiltering.php @@ -23,7 +23,9 @@ public function __construct( 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 @@ public function __construct( $excludePastValidUntil, $apiKey, $defaultDomain, + $domain, ); } @@ -56,6 +59,7 @@ public static function fromLimitsAndParams( $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 70e9dbffc..6749a03f7 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php @@ -9,6 +9,7 @@ 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 @@ private function createListQueryBuilder(ShortUrlsCountFiltering $filtering): Que $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 @@ private function createListQueryBuilder(ShortUrlsCountFiltering $filtering): Que } 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 959249568..995f7218a 100644 --- a/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php +++ b/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php @@ -9,6 +9,7 @@ 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 @@ public function findListReturnsOnlyThoseWithMatchingDomains(): void $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 @@ public function findListReturnsOnlyThoseWithoutExcludedUrls(): void $this->getEntityManager()->flush(); $filtering = static fn (bool $excludeMaxVisitsReached, bool $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))); + new ShortUrlsListFiltering( + excludeMaxVisitsReached: $excludeMaxVisitsReached, + excludePastValidUntil: $excludePastValidUntil, + ); + + 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 d86637614..2ae5c5842 100644 --- a/module/Core/test/ShortUrl/ShortUrlListServiceTest.php +++ b/module/Core/test/ShortUrl/ShortUrlListServiceTest.php @@ -42,7 +42,7 @@ public function listedUrlsAreReturnedFromEntityManager(?ApiKey $apiKey): void $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 a17e51614..1eba6db88 100644 --- a/module/Rest/test-api/Action/ListShortUrlsTest.php +++ b/module/Rest/test-api/Action/ListShortUrlsTest.php @@ -8,6 +8,7 @@ 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 @@ public static function provideFilteredLists(): iterable 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 9fd39d445..34ccd57fb 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -65,6 +65,12 @@ protected function configure(): void 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 @@ protected function execute(InputInterface $input, OutputInterface $output): int $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 @@ protected function execute(InputInterface $input, OutputInterface $output): int $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 8d9c72782..d7c509124 100644 --- a/module/CLI/test-cli/Command/ListShortUrlsTest.php +++ b/module/CLI/test-cli/Command/ListShortUrlsTest.php @@ -70,6 +70,23 @@ public static function provideFlagsAndOutput(): iterable | 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 380c65260..58c396aca 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 9a3e067ab..3dc9e10b4 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 1f436eeb3..76ec36aee 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 c2e5e60d3..61e4a93b8 100644 --- a/module/CLI/src/Command/Domain/DomainRedirectsCommand.php +++ b/module/CLI/src/Command/Domain/DomainRedirectsCommand.php @@ -74,7 +74,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $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 fdff2ddc8..b6fa50342 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 34ccd57fb..d7243dfba 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -210,7 +210,7 @@ private function renderPage( 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 @@ private function resolveColumnsMap(InputInterface $input): array $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 ac8ee1027..41674a79b 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 ceb5cbfd2..ee31ac82e 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 @@ public static function withOlderDb(?Throwable $prev = null): self 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 85ae1d3a5..2abae05b2 100644 --- a/module/CLI/src/GeoLite/GeolocationDbUpdater.php +++ b/module/CLI/src/GeoLite/GeolocationDbUpdater.php @@ -40,8 +40,10 @@ public function __construct( /** * @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 @@ public function checkDbUpdate(?callable $beforeDownload = null, ?callable $handl /** * @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 @@ private function resolveBuildTimestamp(Metadata $meta): int */ 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 @@ private function downloadNewDb( } } - 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 a143abb86..ba0f0e708 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 6183a6c55..74acc1626 100644 --- a/module/CLI/src/Input/DateOption.php +++ b/module/CLI/src/Input/DateOption.php @@ -21,7 +21,7 @@ public function __construct(private Command $command, private string $name, stri $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 8e6df28a5..f20733974 100644 --- a/module/CLI/src/Input/EndDateOption.php +++ b/module/CLI/src/Input/EndDateOption.php @@ -23,7 +23,7 @@ public function __construct(Command $command, string $descriptionHint) )); } - 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 9774d8cbb..29c414078 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 c07de779f..def03f749 100644 --- a/module/CLI/src/Input/ShortUrlIdentifierInput.php +++ b/module/CLI/src/Input/ShortUrlIdentifierInput.php @@ -19,7 +19,7 @@ public function __construct(Command $command, string $shortCodeDesc, string $dom ->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 6a7857d7f..eaef301f3 100644 --- a/module/CLI/src/Input/StartDateOption.php +++ b/module/CLI/src/Input/StartDateOption.php @@ -23,7 +23,7 @@ public function __construct(Command $command, string $descriptionHint) )); } - 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 cb1d3faf2..924876fce 100644 --- a/module/CLI/src/RedirectRule/RedirectRuleHandler.php +++ b/module/CLI/src/RedirectRule/RedirectRuleHandler.php @@ -33,7 +33,7 @@ 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 @@ private function askLongUrl(StyleInterface $io): string 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 @@ private function askMandatory(string $message, StyleInterface $io): string 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 16022768a..e871bc81e 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 5a568dbea..af9577ea8 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 c421c6131..108237344 100644 --- a/module/CLI/src/Util/ShlinkTable.php +++ b/module/CLI/src/Util/ShlinkTable.php @@ -34,8 +34,12 @@ public static function fromBaseTable(Table $baseTable): self 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 482bd36fe..e86cf0e5f 100644 --- a/module/CLI/test/Command/Api/InitialApiKeyCommandTest.php +++ b/module/CLI/test/Command/Api/InitialApiKeyCommandTest.php @@ -27,8 +27,11 @@ public function setUp(): void } #[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 32240fc51..5215c2bc9 100644 --- a/module/CLI/test/Command/Domain/DomainRedirectsCommandTest.php +++ b/module/CLI/test/Command/Domain/DomainRedirectsCommandTest.php @@ -31,7 +31,7 @@ protected function setUp(): void } #[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 19c574819..bd694e7cd 100644 --- a/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php @@ -104,7 +104,7 @@ public function properlyProcessesProvidedTags(): void } #[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 @@ public static function provideDomains(): iterable } #[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 c1a3ab237..ccdab8854 100644 --- a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php @@ -198,12 +198,12 @@ public static function provideOptionalFlags(): iterable #[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 @@ public static function provideArgs(): iterable } #[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 3196dd040..519ddf02e 100644 --- a/module/CLI/test/Exception/GeolocationDbUpdateFailedExceptionTest.php +++ b/module/CLI/test/Exception/GeolocationDbUpdateFailedExceptionTest.php @@ -15,7 +15,7 @@ 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 @@ public function withOlderDbBuildsException(?Throwable $prev): void } #[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 3b0f452e1..c1cd48f52 100644 --- a/module/CLI/test/GeoLite/GeolocationDbUpdaterTest.php +++ b/module/CLI/test/GeoLite/GeolocationDbUpdaterTest.php @@ -180,7 +180,7 @@ public static function provideTrackingOptions(): iterable 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 edd1eae34..18713e001 100644 --- a/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php +++ b/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php @@ -56,7 +56,7 @@ protected function setUp(): void #[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 f7bd0cdf1..00b220e9a 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 46d90056e..3be9097e6 100644 --- a/module/Core/src/Action/Model/QrCodeParams.php +++ b/module/Core/src/Action/Model/QrCodeParams.php @@ -123,7 +123,7 @@ private static function resolveBackgroundColor(array $query, QrCodeOptions $defa 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 6ccb3848a..5ec23bc10 100644 --- a/module/Core/src/Config/EmptyNotFoundRedirectConfig.php +++ b/module/Core/src/Config/EmptyNotFoundRedirectConfig.php @@ -6,7 +6,7 @@ final class EmptyNotFoundRedirectConfig implements NotFoundRedirectConfigInterface { - public function invalidShortUrlRedirect(): ?string + public function invalidShortUrlRedirect(): string|null { return null; } @@ -16,7 +16,7 @@ public function hasInvalidShortUrlRedirect(): bool return false; } - public function regular404Redirect(): ?string + public function regular404Redirect(): string|null { return null; } @@ -26,7 +26,7 @@ public function hasRegular404Redirect(): bool 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 bbdfa9c5f..46c2c7347 100644 --- a/module/Core/src/Config/NotFoundRedirectConfigInterface.php +++ b/module/Core/src/Config/NotFoundRedirectConfigInterface.php @@ -6,15 +6,15 @@ 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 cfb09c8ec..657336c1b 100644 --- a/module/Core/src/Config/NotFoundRedirectResolver.php +++ b/module/Core/src/Config/NotFoundRedirectResolver.php @@ -30,7 +30,7 @@ public function resolveRedirectResponse( 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 6cbdf7023..5f214ca91 100644 --- a/module/Core/src/Config/NotFoundRedirectResolverInterface.php +++ b/module/Core/src/Config/NotFoundRedirectResolverInterface.php @@ -14,5 +14,5 @@ public function resolveRedirectResponse( 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 48437924d..2753d44ff 100644 --- a/module/Core/src/Config/NotFoundRedirects.php +++ b/module/Core/src/Config/NotFoundRedirects.php @@ -9,16 +9,16 @@ 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 e6ef6a241..7c04d0777 100644 --- a/module/Core/src/Config/Options/NotFoundRedirectOptions.php +++ b/module/Core/src/Config/Options/NotFoundRedirectOptions.php @@ -10,9 +10,9 @@ 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 @@ public static function fromEnv(): self ); } - public function invalidShortUrlRedirect(): ?string + public function invalidShortUrlRedirect(): string|null { return $this->invalidShortUrl; } @@ -35,7 +35,7 @@ public function hasInvalidShortUrlRedirect(): bool return $this->invalidShortUrl !== null; } - public function regular404Redirect(): ?string + public function regular404Redirect(): string|null { return $this->regular404; } @@ -45,7 +45,7 @@ public function hasRegular404Redirect(): bool 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 4d85e6ccd..ac864851c 100644 --- a/module/Core/src/Config/Options/QrCodeOptions.php +++ b/module/Core/src/Config/Options/QrCodeOptions.php @@ -26,7 +26,7 @@ public function __construct( 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 eddfba345..d238bb42a 100644 --- a/module/Core/src/Config/Options/TrackingOptions.php +++ b/module/Core/src/Config/Options/TrackingOptions.php @@ -22,7 +22,7 @@ public function __construct( 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 e514af55a..18d66328a 100644 --- a/module/Core/src/Domain/DomainService.php +++ b/module/Core/src/Domain/DomainService.php @@ -26,7 +26,7 @@ public function __construct(private EntityManagerInterface $em, private UrlShort /** * @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 @@ public function listDomains(?ApiKey $apiKey = null): array /** * @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 @@ public function getDomain(string $domainId): Domain 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 @@ public function findByAuthority(string $authority, ?ApiKey $apiKey = null): ?Dom /** * @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 @@ public function getOrCreate(string $authority, ?ApiKey $apiKey = null): Domain 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 @@ public function configureNotFoundRedirects( /** * @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 103abbb2c..b7f8b3eea 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 @@ public function getDomain(string $domainId): Domain; /** * @throws DomainNotFoundException If the API key is restricted to one domain and a different one is provided */ - public function getOrCreate(string $authority, ?ApiKey $apiKey = null): Domain; + public function 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 @@ public function findByAuthority(string $authority, ?ApiKey $apiKey = null): ?Dom 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 ba3446a76..628335cd0 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 @@ public function jsonSerialize(): string return $this->authority; } - public function invalidShortUrlRedirect(): ?string + public function invalidShortUrlRedirect(): string|null { return $this->invalidShortUrlRedirect; } @@ -41,7 +41,7 @@ public function hasInvalidShortUrlRedirect(): bool return $this->invalidShortUrlRedirect !== null; } - public function regular404Redirect(): ?string + public function regular404Redirect(): string|null { return $this->regular404Redirect; } @@ -51,7 +51,7 @@ public function hasRegular404Redirect(): bool 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 fedf4f540..0a1fe40af 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 @@ public function findDomains(?ApiKey $apiKey = null): array 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 @@ public function findOneByAuthority(string $authority, ?ApiKey $apiKey = null): ? 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 @@ public function domainExists(string $authority, ?ApiKey $apiKey = null): bool 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 @@ private function createDomainQueryBuilder(string $authority, ?ApiKey $apiKey): Q 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 d215e475c..cc14bb10a 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 cf7463cc0..2c78a85eb 100644 --- a/module/Core/src/Domain/Spec/IsDomain.php +++ b/module/Core/src/Domain/Spec/IsDomain.php @@ -10,7 +10,7 @@ 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 99f7fbe65..de0c54607 100644 --- a/module/Core/src/ErrorHandler/Model/NotFoundType.php +++ b/module/Core/src/ErrorHandler/Model/NotFoundType.php @@ -13,7 +13,7 @@ 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 4e7360d5a..f84123c12 100644 --- a/module/Core/src/ErrorHandler/NotFoundRedirectHandler.php +++ b/module/Core/src/ErrorHandler/NotFoundRedirectHandler.php @@ -40,7 +40,7 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface 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 cd0f60beb..9b59f886a 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 87f7dba2a..c1fa440aa 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 6f7fb7e88..d1d0d90a8 100644 --- a/module/Core/src/EventDispatcher/LocateVisit.php +++ b/module/Core/src/EventDispatcher/LocateVisit.php @@ -45,7 +45,7 @@ public function __invoke(UrlVisited $shortUrlVisited): void $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 b762af7e8..e9437cc3f 100644 --- a/module/Core/src/EventDispatcher/PublishingUpdatesGenerator.php +++ b/module/Core/src/EventDispatcher/PublishingUpdatesGenerator.php @@ -48,7 +48,7 @@ public function newShortUrlUpdate(ShortUrl $shortUrl): Update ]); } - 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 0cba5a09c..8c7a7d459 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 2ebc3e627..d22d341f3 100644 --- a/module/Core/src/Exception/IpCannotBeLocatedException.php +++ b/module/Core/src/Exception/IpCannotBeLocatedException.php @@ -13,7 +13,7 @@ private function __construct( 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 5336786c3..8f9508a26 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 95da6d5ef..f81c1d37b 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 af0ace92d..5f47280eb 100644 --- a/module/Core/src/Matomo/MatomoOptions.php +++ b/module/Core/src/Matomo/MatomoOptions.php @@ -13,9 +13,9 @@ */ 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 @@ public static function fromEnv(): self ); } - 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 d2a4484a6..9fc0176a5 100644 --- a/module/Core/src/Matomo/MatomoVisitSender.php +++ b/module/Core/src/Matomo/MatomoVisitSender.php @@ -45,7 +45,7 @@ public function sendVisitsInDateRange( 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 e1b1c3cbc..6390104cf 100644 --- a/module/Core/src/Matomo/MatomoVisitSenderInterface.php +++ b/module/Core/src/Matomo/MatomoVisitSenderInterface.php @@ -18,5 +18,5 @@ public function sendVisitsInDateRange( 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 d4b2aaab3..70db853ee 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 3cd3e1324..a4a15cdcd 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 e1b91510a..0e0edab70 100644 --- a/module/Core/src/Model/Ordering.php +++ b/module/Core/src/Model/Ordering.php @@ -10,7 +10,7 @@ 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 890c8845b..e2a3b4140 100644 --- a/module/Core/src/Paginator/Adapter/AbstractCacheableCountPaginatorAdapter.php +++ b/module/Core/src/Paginator/Adapter/AbstractCacheableCountPaginatorAdapter.php @@ -12,7 +12,7 @@ */ 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 59c2798b8..99f5fb9c6 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 aeb08c47d..b6ca5e8ca 100644 --- a/module/Core/src/ShortUrl/DeleteShortUrlService.php +++ b/module/Core/src/ShortUrl/DeleteShortUrlService.php @@ -30,7 +30,7 @@ public function __construct( 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 32eaffa12..e511c9e53 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 ac50064c1..39bc5ed6b 100644 --- a/module/Core/src/ShortUrl/Entity/ShortUrl.php +++ b/module/Core/src/ShortUrl/Entity/ShortUrl.php @@ -49,19 +49,19 @@ private function __construct( 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 @@ public static function withLongUrl(string $longUrl): self 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 @@ public static function create( 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 @@ public static function fromImport( public function update( ShortUrlEdition $shortUrlEdit, - ?ShortUrlRelationResolverInterface $relationResolver = null, + ShortUrlRelationResolverInterface|null $relationResolver = null, ): void { if ($shortUrlEdit->validSinceWasProvided()) { $this->validSince = $shortUrlEdit->validSince; @@ -185,7 +185,7 @@ public function getShortCode(): string return $this->shortCode; } - public function getDomain(): ?Domain + public function getDomain(): Domain|null { return $this->domain; } @@ -195,7 +195,7 @@ public function forwardQuery(): bool return $this->forwardQuery; } - public function title(): ?string + public function title(): string|null { return $this->title; } @@ -205,7 +205,7 @@ public function reachedVisits(int $visitsAmount): bool 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 @@ public function isEnabled(): bool * 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 375d88370..47ac25bfa 100644 --- a/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilder.php +++ b/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilder.php @@ -25,7 +25,7 @@ public function __construct( 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 @@ private function resolveQuery(string $baseQueryString, array $currentQuery): str 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 7f79e98a3..849a3b3fd 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 0950e042f..df52c92d1 100644 --- a/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php +++ b/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php @@ -61,7 +61,7 @@ public function processTitle(TitleResolutionModelInterface $data): TitleResoluti 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 @@ private function fetchUrl(string $url): ?ResponseInterface } } - 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 b164ffd68..7c8689077 100644 --- a/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php +++ b/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php @@ -47,7 +47,7 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface 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 c9c85e1b9..778e8d00d 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlCreation.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlCreation.php @@ -26,17 +26,17 @@ 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 6296f84d6..69b571d0c 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlEdition.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlEdition.php @@ -21,17 +21,17 @@ */ 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 a7c2e2ffc..ff44ed7fc 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlIdentifier.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlIdentifier.php @@ -11,7 +11,7 @@ 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 @@ public static function fromShortUrl(ShortUrl $shortUrl): self 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 e625087ef..7b68ed374 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlsParams.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlsParams.php @@ -22,7 +22,7 @@ private function __construct( 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 @@ public static function fromRawData(array $query): self ); } - 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 b9d4f9936..6bfd91bc6 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 3cd7744aa..5cd7fe38d 100644 --- a/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php +++ b/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php @@ -109,7 +109,7 @@ private function initializeForEdition(bool $requireLongUrl = false): void $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 ac3379df1..1a7b97de5 100644 --- a/module/Core/src/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapter.php +++ b/module/Core/src/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapter.php @@ -18,7 +18,7 @@ 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 b27fe7c56..a8e42236f 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 @@ public function __construct( ); } - 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 b3946ab1b..d0fa64180 100644 --- a/module/Core/src/ShortUrl/Persistence/ShortUrlsListFiltering.php +++ b/module/Core/src/ShortUrl/Persistence/ShortUrlsListFiltering.php @@ -13,19 +13,19 @@ 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 @@ public static function fromLimitsAndParams( 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 015c8eac8..bb6abea23 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlRepository.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlRepository.php @@ -23,7 +23,7 @@ /** @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 @@ public function findOneWithDomainFallback(ShortUrlIdentifier $identifier, 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 @@ public function findOne(ShortUrlIdentifier $identifier, ?Specification $spec = n 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 @@ public function shortCodeIsInUseWithLock(ShortUrlIdentifier $identifier, ?Specif /** * @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 @@ private function doShortCodeIsInUse(ShortUrlIdentifier $identifier, ?Specificati 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 @@ private function createFindOneQueryBuilder(ShortUrlIdentifier $identifier, ?Spec return $qb; } - public function findOneMatching(ShortUrlCreation $creation): ?ShortUrl + public function findOneMatching(ShortUrlCreation $creation): ShortUrl|null { $qb = $this->getEntityManager()->createQueryBuilder(); @@ -166,7 +169,7 @@ private function joinAllTags(QueryBuilder $qb, array $tags): void } } - 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 @@ public function findOneByImportedUrl(ImportedShlinkUrl $url): ?ShortUrl 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 d0934197a..a96d0be81 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlRepositoryInterface.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlRepositoryInterface.php @@ -16,15 +16,18 @@ /** @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 94fb314a3..2e5f3e154 100644 --- a/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php +++ b/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php @@ -38,7 +38,7 @@ public function __construct( $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 b52282148..6af627b55 100644 --- a/module/Core/src/ShortUrl/Resolver/ShortUrlRelationResolverInterface.php +++ b/module/Core/src/ShortUrl/Resolver/ShortUrlRelationResolverInterface.php @@ -10,7 +10,7 @@ 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 c1a9d0ab3..5702c346f 100644 --- a/module/Core/src/ShortUrl/Resolver/SimpleShortUrlRelationResolver.php +++ b/module/Core/src/ShortUrl/Resolver/SimpleShortUrlRelationResolver.php @@ -12,7 +12,7 @@ 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 853a40b93..2a1adb26e 100644 --- a/module/Core/src/ShortUrl/ShortUrlListService.php +++ b/module/Core/src/ShortUrl/ShortUrlListService.php @@ -22,7 +22,7 @@ public function __construct( /** * @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 b83abd4c9..a8b8b2cc8 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 14727ff56..0f32768d5 100644 --- a/module/Core/src/ShortUrl/ShortUrlResolver.php +++ b/module/Core/src/ShortUrl/ShortUrlResolver.php @@ -23,7 +23,7 @@ public function __construct( /** * @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 9dd522c0f..bcf7d40a3 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 d75f847d8..b2c7e92fa 100644 --- a/module/Core/src/ShortUrl/ShortUrlService.php +++ b/module/Core/src/ShortUrl/ShortUrlService.php @@ -29,7 +29,7 @@ public function __construct( 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 c7892f551..fde21a70d 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 8ad6713f3..e8a076544 100644 --- a/module/Core/src/ShortUrl/ShortUrlVisitsDeleter.php +++ b/module/Core/src/ShortUrl/ShortUrlVisitsDeleter.php @@ -21,7 +21,7 @@ public function __construct( /** * @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 46e9fde5c..625880dcf 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 3c95593cf..42f9c7221 100644 --- a/module/Core/src/ShortUrl/Spec/BelongsToApiKey.php +++ b/module/Core/src/ShortUrl/Spec/BelongsToApiKey.php @@ -11,7 +11,7 @@ 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 33eacec8e..4a2aae622 100644 --- a/module/Core/src/ShortUrl/Spec/BelongsToDomain.php +++ b/module/Core/src/ShortUrl/Spec/BelongsToDomain.php @@ -10,7 +10,7 @@ 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 4a908c787..a0692e063 100644 --- a/module/Core/src/ShortUrl/UrlShortener.php +++ b/module/Core/src/ShortUrl/UrlShortener.php @@ -64,7 +64,7 @@ public function shorten(ShortUrlCreation $creation): UrlShorteningResult 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 994e6d63e..d373a59d0 100644 --- a/module/Core/src/Spec/InDateRange.php +++ b/module/Core/src/Spec/InDateRange.php @@ -11,7 +11,7 @@ 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 39092e4d7..0b7a42727 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 504181ecb..dfa255bd9 100644 --- a/module/Core/src/Tag/Model/TagInfo.php +++ b/module/Core/src/Tag/Model/TagInfo.php @@ -15,7 +15,7 @@ public function __construct( 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 236dde4a4..d8da71b74 100644 --- a/module/Core/src/Tag/Model/TagsListFiltering.php +++ b/module/Core/src/Tag/Model/TagsListFiltering.php @@ -10,15 +10,15 @@ 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 d094bcc0f..7207b1a8b 100644 --- a/module/Core/src/Tag/Model/TagsParams.php +++ b/module/Core/src/Tag/Model/TagsParams.php @@ -12,10 +12,10 @@ 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 98126e278..e26ba2f47 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 a2820e7b8..4545e46c1 100644 --- a/module/Core/src/Tag/Repository/TagRepository.php +++ b/module/Core/src/Tag/Repository/TagRepository.php @@ -42,7 +42,7 @@ public function deleteByName(array $names): int /** * @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 @@ public function findTagsWithInfo(?TagsListFiltering $filtering = null): array ); } - 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 ccb33de04..236beb14d 100644 --- a/module/Core/src/Tag/Repository/TagRepositoryInterface.php +++ b/module/Core/src/Tag/Repository/TagRepositoryInterface.php @@ -19,7 +19,7 @@ public function deleteByName(array $names): int; /** * @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 de16fada1..e3e5b92fb 100644 --- a/module/Core/src/Tag/TagService.php +++ b/module/Core/src/Tag/TagService.php @@ -28,7 +28,7 @@ public function __construct(private ORM\EntityManagerInterface $em) /** * @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 @@ public function listTags(TagsParams $params, ?ApiKey $apiKey = null): Paginator /** * @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 @@ private function createPaginator(AdapterInterface $adapter, TagsParams $params): /** * @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 @@ public function deleteTags(array $tagNames, ?ApiKey $apiKey = null): void /** * @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 60aeb7c73..c09370cf6 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 66354c373..9adfa97dc 100644 --- a/module/Core/src/Util/IpAddressUtils.php +++ b/module/Core/src/Util/IpAddressUtils.php @@ -56,7 +56,7 @@ public static function ipAddressMatchesGroups(string $ipAddress, array $groups): * * @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 @@ private static function candidateToRange(string $candidate, array $ipAddressPart * 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 be8400dcf..d02d7298d 100644 --- a/module/Core/src/Visit/Entity/Visit.php +++ b/module/Core/src/Visit/Entity/Visit.php @@ -21,14 +21,14 @@ 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 @@ public static function forRegularNotFound(Visitor $visitor, bool $anonymize = tr 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 @@ private static function fromVisitor(?ShortUrl $shortUrl, VisitType $type, Visito ); } - 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 @@ public static function fromOrphanImport(ImportedShlinkOrphanVisit $importedVisit 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 @@ public function hasRemoteAddr(): bool 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 0fb2e99bd..0e6afedc8 100644 --- a/module/Core/src/Visit/Model/OrphanVisitsParams.php +++ b/module/Core/src/Visit/Model/OrphanVisitsParams.php @@ -12,11 +12,11 @@ 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 ca5d79b2e..c914f334d 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 107131315..31e6e67d9 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 22f05bd4b..2f812aef8 100644 --- a/module/Core/src/Visit/Model/VisitsStats.php +++ b/module/Core/src/Visit/Model/VisitsStats.php @@ -14,8 +14,8 @@ 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 330d86920..184ecdd1b 100644 --- a/module/Core/src/Visit/Paginator/Adapter/DomainVisitsPaginatorAdapter.php +++ b/module/Core/src/Visit/Paginator/Adapter/DomainVisitsPaginatorAdapter.php @@ -21,7 +21,7 @@ public function __construct( 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 7929bcfd1..5e3cdbe1e 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 9deedf9a9..899ab8311 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 43bc02fff..efd68035f 100644 --- a/module/Core/src/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapter.php +++ b/module/Core/src/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapter.php @@ -20,7 +20,7 @@ public function __construct( 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 93a182bd6..909bd2baf 100644 --- a/module/Core/src/Visit/Paginator/Adapter/TagVisitsPaginatorAdapter.php +++ b/module/Core/src/Visit/Paginator/Adapter/TagVisitsPaginatorAdapter.php @@ -19,7 +19,7 @@ public function __construct( 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 88676df8b..c09bc5ca7 100644 --- a/module/Core/src/Visit/Persistence/OrphanVisitsCountFiltering.php +++ b/module/Core/src/Visit/Persistence/OrphanVisitsCountFiltering.php @@ -11,10 +11,10 @@ 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 c2873cdf3..d1e49605e 100644 --- a/module/Core/src/Visit/Persistence/OrphanVisitsListFiltering.php +++ b/module/Core/src/Visit/Persistence/OrphanVisitsListFiltering.php @@ -11,12 +11,12 @@ 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 570abc191..8948c9602 100644 --- a/module/Core/src/Visit/Persistence/VisitsCountFiltering.php +++ b/module/Core/src/Visit/Persistence/VisitsCountFiltering.php @@ -10,9 +10,9 @@ 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 747a3ce0b..eded82eb8 100644 --- a/module/Core/src/Visit/Persistence/VisitsListFiltering.php +++ b/module/Core/src/Visit/Persistence/VisitsListFiltering.php @@ -10,11 +10,11 @@ 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 71590d7ea..1370ed205 100644 --- a/module/Core/src/Visit/Repository/VisitIterationRepository.php +++ b/module/Core/src/Visit/Repository/VisitIterationRepository.php @@ -48,7 +48,7 @@ public function findVisitsWithEmptyLocation(int $blockSize = self::DEFAULT_BLOCK /** * @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 d4ffb864a..2f4163240 100644 --- a/module/Core/src/Visit/Repository/VisitIterationRepositoryInterface.php +++ b/module/Core/src/Visit/Repository/VisitIterationRepositoryInterface.php @@ -24,5 +24,8 @@ public function findVisitsWithEmptyLocation(int $blockSize = self::DEFAULT_BLOCK /** * @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 1df109b34..1c85fe666 100644 --- a/module/Core/src/Visit/Repository/VisitRepository.php +++ b/module/Core/src/Visit/Repository/VisitRepository.php @@ -203,7 +203,7 @@ private function createAllVisitsQueryBuilder(VisitsListFiltering|OrphanVisitsLis 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 @@ private function applyDatesInline(QueryBuilder $qb, ?DateRange $dateRange): void } } - 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 @@ private function resolveVisitsWithNativeQuery(QueryBuilder $qb, ?int $limit, ?in 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 2b925e17d..fb0f231a7 100644 --- a/module/Core/src/Visit/VisitsDeleter.php +++ b/module/Core/src/Visit/VisitsDeleter.php @@ -15,7 +15,7 @@ public function __construct(private readonly VisitDeleterRepositoryInterface $re { } - 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 3a75a0d3b..67fa5e725 100644 --- a/module/Core/src/Visit/VisitsDeleterInterface.php +++ b/module/Core/src/Visit/VisitsDeleterInterface.php @@ -9,5 +9,5 @@ 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 0952670be..f1533ddf5 100644 --- a/module/Core/src/Visit/VisitsStatsHelper.php +++ b/module/Core/src/Visit/VisitsStatsHelper.php @@ -41,7 +41,7 @@ public function __construct(private EntityManagerInterface $em) { } - 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 @@ public function getVisitsStats(?ApiKey $apiKey = null): VisitsStats 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 @@ public function visitsForShortUrl( /** * @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 @@ public function visitsForTag(string $tag, VisitsParams $params, ?ApiKey $apiKey /** * @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 @@ public function visitsForDomain(string $domain, VisitsParams $params, ?ApiKey $a /** * @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 @@ public function orphanVisits(OrphanVisitsParams $params, ?ApiKey $apiKey = null) 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 87e0980b4..12e589335 100644 --- a/module/Core/src/Visit/VisitsStatsHelperInterface.php +++ b/module/Core/src/Visit/VisitsStatsHelperInterface.php @@ -17,7 +17,7 @@ interface VisitsStatsHelperInterface { - public function getVisitsStats(?ApiKey $apiKey = null): VisitsStats; + public function getVisitsStats(ApiKey|null $apiKey = null): VisitsStats; /** * @return Paginator @@ -26,28 +26,28 @@ public function getVisitsStats(?ApiKey $apiKey = null): VisitsStats; 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 58817f388..0bae6bd8f 100644 --- a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php +++ b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php @@ -128,7 +128,7 @@ public function expectedDomainsAreFoundWhenApiKeyIsProvided(): void 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 @@ public function __construct(private Domain $domain) { } - 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 074acdd45..535ca50f7 100644 --- a/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php +++ b/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php @@ -404,7 +404,7 @@ public function findOneMatchingAppliesProvidedApiKeyConditions(): void #[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 f88a8e7f5..b7027f97f 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 @@ protected function setUp(): void */ #[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 77f6aa6aa..34210dbec 100644 --- a/module/Core/test-db/Tag/Repository/TagRepositoryTest.php +++ b/module/Core/test-db/Tag/Repository/TagRepositoryTest.php @@ -57,7 +57,7 @@ public function allTagsWhichMatchNameAreDeleted(): void } #[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 @@ public function properTagsInfoIsReturned(?TagsListFiltering $filtering, array $e [$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 8d7579b7f..393e41da6 100644 --- a/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php +++ b/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php @@ -534,7 +534,7 @@ public function findMostRecentOrphanVisitReturnsExpectedVisit(): void 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 5e499403c..f8dea217c 100644 --- a/module/Core/test/Action/QrCodeActionTest.php +++ b/module/Core/test/Action/QrCodeActionTest.php @@ -190,7 +190,7 @@ public static function provideRequestsWithSize(): iterable #[Test, DataProvider('provideRoundBlockSize')] public function imageCanRemoveExtraMarginWhenBlockRoundIsDisabled( QrCodeOptions $defaultOptions, - ?string $roundBlockSize, + string|null $roundBlockSize, int $expectedColor, ): void { $code = 'abc123'; @@ -234,7 +234,7 @@ public static function provideRoundBlockSize(): iterable } #[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 @@ public static function provideEnabled(): iterable 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 80d5203ac..39e37f32e 100644 --- a/module/Core/test/Config/PostProcessor/ShortUrlMethodsProcessorTest.php +++ b/module/Core/test/Config/PostProcessor/ShortUrlMethodsProcessorTest.php @@ -23,14 +23,14 @@ protected function setUp(): void #[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 7e2fea189..b7f78c6b2 100644 --- a/module/Core/test/Domain/DomainServiceTest.php +++ b/module/Core/test/Domain/DomainServiceTest.php @@ -33,7 +33,7 @@ protected function setUp(): void } #[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 @@ public function getDomainReturnsEntityWhenFound(): void } #[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 @@ public function getOrCreateThrowsExceptionForApiKeysWithDomainRole(): void } #[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 63595a6ce..80e3e318f 100644 --- a/module/Core/test/EventDispatcher/LocateVisitTest.php +++ b/module/Core/test/EventDispatcher/LocateVisitTest.php @@ -155,7 +155,7 @@ public static function provideNonLocatableVisits(): iterable } #[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 10726273e..ed0ada96a 100644 --- a/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php +++ b/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php @@ -57,7 +57,7 @@ public function visitIsNotSentWhenItDoesNotExist(): void } #[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 08b4cdb72..7686f4abd 100644 --- a/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php +++ b/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php @@ -41,7 +41,7 @@ protected function tearDown(): void } #[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 ac744824a..1117d5d39 100644 --- a/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php +++ b/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php @@ -179,7 +179,7 @@ function (MockObject & PublishingHelperInterface $helper) use ($once): void { ]; } - 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 dc6045218..3b20ab0c3 100644 --- a/module/Core/test/EventDispatcher/UpdateGeoLiteDbTest.php +++ b/module/Core/test/EventDispatcher/UpdateGeoLiteDbTest.php @@ -77,7 +77,7 @@ public function noticeMessageIsPrintedWhenSecondCallbackIsInvoked( 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 c1e0d1582..84ec48eb7 100644 --- a/module/Core/test/Exception/NonUniqueSlugExceptionTest.php +++ b/module/Core/test/Exception/NonUniqueSlugExceptionTest.php @@ -12,7 +12,7 @@ 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 aee8a29f1..62e0afa2f 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 5bb3baa81..3cb872504 100644 --- a/module/Core/test/Exception/ValidationExceptionTest.php +++ b/module/Core/test/Exception/ValidationExceptionTest.php @@ -20,7 +20,7 @@ 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 a18165637..36265aa37 100644 --- a/module/Core/test/Importer/ImportedLinksProcessorTest.php +++ b/module/Core/test/Importer/ImportedLinksProcessorTest.php @@ -127,7 +127,7 @@ public function alreadyImportedUrlsAreSkipped(): void $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 @@ public function properAmountOfVisitsIsImported( 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 @@ public static function provideUrlsWithVisits(): iterable } #[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 @@ public static function provideFoundShortUrls(): iterable 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 1b55405ee..e0bd0fde8 100644 --- a/module/Core/test/Matomo/MatomoTrackerBuilderTest.php +++ b/module/Core/test/Matomo/MatomoTrackerBuilderTest.php @@ -43,7 +43,7 @@ public function trackerIsCreated(): void 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 6a4659f19..bf568bfb6 100644 --- a/module/Core/test/Matomo/MatomoVisitSenderTest.php +++ b/module/Core/test/Matomo/MatomoVisitSenderTest.php @@ -43,7 +43,7 @@ protected function setUp(): void } #[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 3cd44ef0c..b31d1fd3b 100644 --- a/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php +++ b/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php @@ -42,7 +42,7 @@ public function matchesQueryParams(string $param, string $value, bool $expectedR #[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 @@ public function matchesLanguage(?string $acceptLanguage, string $value, bool $ex #[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 @@ public function matchesDevice(?string $userAgent, DeviceType $value, bool $expec #[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 3bf23863d..f26627c60 100644 --- a/module/Core/test/RedirectRule/ShortUrlRedirectionResolverTest.php +++ b/module/Core/test/RedirectRule/ShortUrlRedirectionResolverTest.php @@ -36,7 +36,7 @@ protected function setUp(): void #[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 b37202545..29e9d88c4 100644 --- a/module/Core/test/ShortUrl/Entity/ShortUrlTest.php +++ b/module/Core/test/ShortUrl/Entity/ShortUrlTest.php @@ -75,7 +75,7 @@ public static function provideValidShortUrls(): iterable } #[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 @@ public static function provideLengths(): iterable #[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 de1402f64..f341585e4 100644 --- a/module/Core/test/ShortUrl/Helper/ShortCodeUniquenessHelperTest.php +++ b/module/Core/test/ShortUrl/Helper/ShortCodeUniquenessHelperTest.php @@ -32,7 +32,7 @@ protected function setUp(): void } #[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 d1283a78b..6f48a8363 100644 --- a/module/Core/test/ShortUrl/Helper/ShortUrlRedirectionBuilderTest.php +++ b/module/Core/test/ShortUrl/Helper/ShortUrlRedirectionBuilderTest.php @@ -35,8 +35,8 @@ protected function setUp(): void 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 d28fdf0e6..03799e105 100644 --- a/module/Core/test/ShortUrl/Helper/ShortUrlStringifierTest.php +++ b/module/Core/test/ShortUrl/Helper/ShortUrlStringifierTest.php @@ -32,7 +32,7 @@ public function generatesExpectedOutputBasedOnConfigAndShortUrl( 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 6815acb65..851680203 100644 --- a/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php +++ b/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php @@ -70,7 +70,7 @@ public function handlerIsCalledWhenConfigPreventsRedirectWithExtraPath( 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 @@ public function handlerIsCalledWhenNoShortUrlIsFoundAfterExpectedAmountOfIterati public function visitIsTrackedAndRedirectIsReturnedWhenShortUrlIsFoundAfterExpectedAmountOfIterations( bool $multiSegmentEnabled, int $expectedResolveCalls, - ?string $expectedExtraPath, + string|null $expectedExtraPath, ): void { $options = new UrlShortenerOptions(appendExtraPath: true, multiSegmentSlugsEnabled: $multiSegmentEnabled); @@ -170,7 +170,7 @@ public static function provideResolves(): iterable 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 e963923b8..ed9c64597 100644 --- a/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php +++ b/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php @@ -147,7 +147,7 @@ public static function provideValidLongUrls(): iterable } #[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 @@ public static function provideTitles(): iterable } #[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 2ef213d2f..473c23202 100644 --- a/module/Core/test/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php +++ b/module/Core/test/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php @@ -28,11 +28,11 @@ protected function setUp(): void #[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 @@ public function getItemsFallsBackToFindList( #[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 722ac347e..934d85118 100644 --- a/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php +++ b/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php @@ -33,7 +33,7 @@ protected function setUp(): void } #[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 @@ public static function provideDomainsThatEmpty(): iterable } #[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 @@ public function findsAndPersistsTagsWrappedIntoCollection(array $tags, array $ex $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 f74480ba3..95e957850 100644 --- a/module/Core/test/ShortUrl/Resolver/SimpleShortUrlRelationResolverTest.php +++ b/module/Core/test/ShortUrl/Resolver/SimpleShortUrlRelationResolverTest.php @@ -21,7 +21,7 @@ protected function setUp(): void } #[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 2ae5c5842..c22bc206f 100644 --- a/module/Core/test/ShortUrl/ShortUrlListServiceTest.php +++ b/module/Core/test/ShortUrl/ShortUrlListServiceTest.php @@ -30,7 +30,7 @@ protected function setUp(): void } #[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 e8443a13e..24571b418 100644 --- a/module/Core/test/ShortUrl/ShortUrlResolverTest.php +++ b/module/Core/test/ShortUrl/ShortUrlResolverTest.php @@ -42,7 +42,7 @@ protected function setUp(): void } #[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 @@ public function shortCodeIsProperlyParsed(?ApiKey $apiKey): void } #[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 669015a06..c35543639 100644 --- a/module/Core/test/ShortUrl/ShortUrlServiceTest.php +++ b/module/Core/test/ShortUrl/ShortUrlServiceTest.php @@ -48,7 +48,7 @@ protected function setUp(): void 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 f22a35f2e..7e82eb1cc 100644 --- a/module/Core/test/Tag/TagServiceTest.php +++ b/module/Core/test/Tag/TagServiceTest.php @@ -55,7 +55,7 @@ public function listTagsDelegatesOnRepository(): void #[Test, DataProvider('provideApiKeysAndSearchTerm')] public function tagsInfoDelegatesOnRepository( - ?ApiKey $apiKey, + ApiKey|null $apiKey, TagsParams $params, TagsListFiltering $expectedFiltering, int $countCalls, @@ -101,7 +101,7 @@ public static function provideApiKeysAndSearchTerm(): iterable } #[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 @@ public function deleteTagsThrowsExceptionWhenProvidedApiKeyIsNotAdmin(): void } #[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 @@ public static function provideValidRenames(): iterable } #[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 89d3fa5ae..b01333d51 100644 --- a/module/Core/test/Util/RedirectResponseHelperTest.php +++ b/module/Core/test/Util/RedirectResponseHelperTest.php @@ -18,7 +18,7 @@ public function expectedStatusCodeAndCacheIsReturnedBasedOnConfig( int $configuredStatus, int $configuredLifetime, int $expectedStatus, - ?string $expectedCacheControl, + string|null $expectedCacheControl, ): void { $options = new RedirectOptions($configuredStatus, $configuredLifetime); @@ -46,7 +46,7 @@ public static function provideRedirectConfigs(): iterable 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 923b2e6b7..3556c1f1c 100644 --- a/module/Core/test/Visit/Entity/VisitTest.php +++ b/module/Core/test/Visit/Entity/VisitTest.php @@ -103,8 +103,11 @@ public static function provideOrphanVisits(): iterable } #[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 c96e5be51..d1f7c89bd 100644 --- a/module/Core/test/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapterTest.php +++ b/module/Core/test/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapterTest.php @@ -58,7 +58,7 @@ public function repoIsCalledOnlyOnceForCount(): void } } - 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 59ce2082c..c0cd4d0b8 100644 --- a/module/Core/test/Visit/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php +++ b/module/Core/test/Visit/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php @@ -57,7 +57,7 @@ public function repoIsCalledOnlyOnceForCount(): void } } - 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 61fb12932..10c11b641 100644 --- a/module/Core/test/Visit/VisitsStatsHelperTest.php +++ b/module/Core/test/Visit/VisitsStatsHelperTest.php @@ -56,7 +56,7 @@ protected function setUp(): void } #[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 @@ public static function provideCounts(): iterable } #[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 @@ public function throwsExceptionWhenRequestingVisitsForInvalidTag(): void } #[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 @@ public function throwsExceptionWhenRequestingVisitsForInvalidDomain(): void } #[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 @@ public function visitsForNonDefaultDomainAreReturnedAsExpected(?ApiKey $apiKey): } #[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 414f5254a..f45a27d81 100644 --- a/module/Core/test/Visit/VisitsTrackerTest.php +++ b/module/Core/test/Visit/VisitsTrackerTest.php @@ -79,7 +79,7 @@ public static function provideOrphanTrackingMethodNames(): iterable 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 e2b27e230..0c55f967d 100644 --- a/module/Rest/src/Action/Domain/Request/DomainRedirectsRequest.php +++ b/module/Rest/src/Action/Domain/Request/DomainRedirectsRequest.php @@ -14,11 +14,11 @@ 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 @@ public function authority(): string 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 e28a9ec3f..020c1f27d 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 @@ public static function empty(): self * @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 2b7aa0a2b..2d82b23ee 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 57c2a7f62..04e555199 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 4f3685dbf..7cca292d6 100644 --- a/module/Rest/src/ApiKey/Role.php +++ b/module/Rest/src/ApiKey/Role.php @@ -38,7 +38,7 @@ public function paramName(): 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 122829ed0..d7c2e7ba3 100644 --- a/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php +++ b/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php @@ -11,8 +11,10 @@ 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 067c69524..1768c7c8b 100644 --- a/module/Rest/src/ConfigProvider.php +++ b/module/Rest/src/ConfigProvider.php @@ -33,7 +33,7 @@ public static function applyRoutesPrefix(array $routes): array 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 46548dcf6..1cca4f3aa 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 @@ public function toString(): string 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 @@ public function inlinedSpec(): Specification /** * @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 @@ public static function isAdmin(?ApiKey $apiKey): bool /** * 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 ff74fb79c..4a1fc1cfc 100644 --- a/module/Rest/src/Service/ApiKeyCheckResult.php +++ b/module/Rest/src/Service/ApiKeyCheckResult.php @@ -8,7 +8,7 @@ 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 21f69f903..6c825a4a3 100644 --- a/module/Rest/src/Service/ApiKeyService.php +++ b/module/Rest/src/Service/ApiKeyService.php @@ -28,7 +28,7 @@ public function create(ApiKeyMeta $apiKeyMeta): ApiKey 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 @@ public function listKeys(bool $enabledOnly = false): array 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 b82d7760d..167041c5f 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 42742bbba..212b545cc 100644 --- a/module/Rest/test-api/Action/CreateShortUrlTest.php +++ b/module/Rest/test-api/Action/CreateShortUrlTest.php @@ -39,7 +39,7 @@ public function createsNewShortUrlWithCustomSlug(): void } #[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 @@ public static function provideMatchingBodies(): iterable } #[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 @@ public function defaultDomainIsDroppedIfProvided(): void } #[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 @@ public function titleIsIgnoredIfLongUrlTimesOut(): void #[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 06848c48b..90df09c4a 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 24f91e581..2146a95cd 100644 --- a/module/Rest/test-api/Action/EditShortUrlTest.php +++ b/module/Rest/test-api/Action/EditShortUrlTest.php @@ -90,7 +90,7 @@ public function longUrlCanBeEdited(): void #[Test, DataProviderExternal(ApiTestDataProviders::class, 'invalidUrlsProvider')] public function tryingToEditInvalidUrlReturnsNotFoundError( string $shortCode, - ?string $domain, + string|null $domain, string $expectedDetail, string $apiKey, ): void { @@ -125,7 +125,7 @@ public function providingInvalidDataReturnsBadRequest(): void } #[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 0c0ce5ec4..e02c92474 100644 --- a/module/Rest/test-api/Action/ResolveShortUrlTest.php +++ b/module/Rest/test-api/Action/ResolveShortUrlTest.php @@ -45,7 +45,7 @@ public static function provideDisabledMeta(): iterable #[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 8db002c4b..658fe88ac 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 @@ public function tryingToGetVisitsForInvalidUrlReturnsNotFoundError( } #[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 038e3f383..974ac0a57 100644 --- a/module/Rest/test-api/Action/SingleStepCreateShortUrlTest.php +++ b/module/Rest/test-api/Action/SingleStepCreateShortUrlTest.php @@ -13,7 +13,7 @@ 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 @@ public function authorizationErrorIsReturnedIfNoApiKeyIsSent(): void 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 949a80c3a..1b4f64eaa 100644 --- a/module/Rest/test-api/Fixtures/ApiKeyFixture.php +++ b/module/Rest/test-api/Fixtures/ApiKeyFixture.php @@ -49,7 +49,7 @@ public function load(ObjectManager $manager): void $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 6de96a81c..e61d6ad4c 100644 --- a/module/Rest/test-api/Utils/UrlBuilder.php +++ b/module/Rest/test-api/Utils/UrlBuilder.php @@ -11,7 +11,7 @@ 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 292c17484..45faf9f2a 100644 --- a/module/Rest/test/Action/Domain/Request/DomainRedirectsRequestTest.php +++ b/module/Rest/test/Action/Domain/Request/DomainRedirectsRequestTest.php @@ -30,11 +30,11 @@ public static function provideInvalidData(): iterable #[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 ce4ad04f3..69bfb56aa 100644 --- a/module/Rest/test/Action/MercureInfoActionTest.php +++ b/module/Rest/test/Action/MercureInfoActionTest.php @@ -49,7 +49,7 @@ public function provideValidConfigs(): iterable } #[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 a3beba1b0..ae99f0a99 100644 --- a/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php +++ b/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php @@ -39,11 +39,11 @@ protected function setUp(): void 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 5d0360975..7545cd6a0 100644 --- a/module/Rest/test/Action/Tag/DeleteTagsActionTest.php +++ b/module/Rest/test/Action/Tag/DeleteTagsActionTest.php @@ -25,7 +25,7 @@ protected function setUp(): void } #[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 100b146e7..b74a435fa 100644 --- a/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php +++ b/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php @@ -82,7 +82,7 @@ public function optionsRequestIncludesMoreHeaders(): void #[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 c847027c6..7f4d0eba7 100644 --- a/module/Rest/test/Middleware/ShortUrl/CreateShortUrlContentNegotiationMiddlewareTest.php +++ b/module/Rest/test/Middleware/ShortUrl/CreateShortUrlContentNegotiationMiddlewareTest.php @@ -40,7 +40,7 @@ public function whenNoJsonResponseIsReturnedNoFurtherOperationsArePerformed(): v } #[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 453640702..a799da279 100644 --- a/module/Rest/test/Service/ApiKeyServiceTest.php +++ b/module/Rest/test/Service/ApiKeyServiceTest.php @@ -35,7 +35,7 @@ protected function setUp(): void * @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 @@ public static function provideCreationDate(): iterable } #[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 @@ public function listEnabledFindsOnlyEnabledApiKeys(): void } #[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 a2f4def8b..dc6823223 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 1eaa0f28d..186a95110 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 17e748b87..f63a2d7e0 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 30f2286db..4c7b9f109 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 3dc9e10b4..fc032e708 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 6659bc0c0..36dd9a604 100644 --- a/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php +++ b/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php @@ -7,6 +7,7 @@ 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 @@ public function __construct( ) { } - 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 0505a6942..0a6f69755 100644 --- a/module/Core/src/ShortUrl/Helper/ShortUrlStringifierInterface.php +++ b/module/Core/src/ShortUrl/Helper/ShortUrlStringifierInterface.php @@ -5,8 +5,9 @@ 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 ff44ed7fc..9b3014f81 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlIdentifier.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlIdentifier.php @@ -33,10 +33,8 @@ public static function fromRedirectRequest(ServerRequestInterface $request): sel 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 d5c34b8b2..6b824cf88 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlWithVisitsSummary.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlWithVisitsSummary.php @@ -11,8 +11,8 @@ { private function __construct( public ShortUrl $shortUrl, + private string|null $authority, private VisitsSummary|null $visitsSummary = null, - private string|null $authority = null, ) { } @@ -23,17 +23,22 @@ public static function fromArray(array $data): self { 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 d2bdb73a9..d19262e1a 100644 --- a/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php +++ b/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php @@ -6,6 +6,7 @@ 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 @@ public function __construct(private ShortUrlStringifierInterface $stringifier) { } - 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 e1101f701..b3103c80b 100644 --- a/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformerInterface.php +++ b/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformerInterface.php @@ -9,5 +9,5 @@ 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 d7243dfba..72a92fe85 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -13,7 +13,7 @@ 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 @@ protected function execute(InputInterface $input, OutputInterface $output): int /** * @param array $columnsMap - * @return Paginator + * @return Paginator */ private function renderPage( OutputInterface $output, @@ -196,7 +196,7 @@ private function renderPage( ): 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 ccdab8854..41b3fe88d 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\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 @@ public function loadingMorePagesCallsListMoreTimes(): void // 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 @@ public function havingMorePagesButAnsweringNoCallsListJustOnce(): void // 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 @@ public function provideOptionalFlagsMakesNewColumnsToBeIncluded( $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 6b824cf88..4b9b5a705 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlWithVisitsSummary.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlWithDeps.php @@ -7,7 +7,7 @@ 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 1a7b97de5..4daa5cb96 100644 --- a/module/Core/src/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapter.php +++ b/module/Core/src/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapter.php @@ -6,13 +6,13 @@ 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 6749a03f7..c18b31eff 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php @@ -12,7 +12,7 @@ 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 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements ShortUrlListRepositoryInterface { /** - * @return ShortUrlWithVisitsSummary[] + * @return ShortUrlWithDeps[] */ public function findList(ShortUrlsListFiltering $filtering): array { @@ -59,7 +59,7 @@ public function findList(ShortUrlsListFiltering $filtering): array /** @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 db3f8017d..d71f6297b 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlListRepositoryInterface.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlListRepositoryInterface.php @@ -4,14 +4,14 @@ 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 a8b8b2cc8..9ece5cada 100644 --- a/module/Core/src/ShortUrl/ShortUrlListServiceInterface.php +++ b/module/Core/src/ShortUrl/ShortUrlListServiceInterface.php @@ -6,13 +6,13 @@ 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 d19262e1a..2692f76bb 100644 --- a/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php +++ b/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php @@ -7,7 +7,7 @@ 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 @@ public function __construct(private ShortUrlStringifierInterface $stringifier) { } - 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 b3103c80b..cd8aeb370 100644 --- a/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformerInterface.php +++ b/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformerInterface.php @@ -5,9 +5,9 @@ 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 995f7218a..051093656 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\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 @@ public function findListProperlyFiltersResult(): void $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 77cbaa43c..681a9495c 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 58c396aca..a6741b550 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 fc032e708..1100f0991 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 0a35bef70..a7656189f 100644 --- a/module/CLI/src/Command/Api/GenerateKeyCommand.php +++ b/module/CLI/src/Command/Api/GenerateKeyCommand.php @@ -109,7 +109,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int )); $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 40ae8eef8..ab10ebc6a 100644 --- a/module/CLI/src/Command/Api/ListKeysCommand.php +++ b/module/CLI/src/Command/Api/ListKeysCommand.php @@ -54,7 +54,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $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 72a92fe85..900402e1e 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -244,7 +244,7 @@ private function resolveColumnsMap(InputInterface $input): array } 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 478dbaa52..6f4b816bb 100644 --- a/module/CLI/test/Command/Api/ListKeysCommandTest.php +++ b/module/CLI/test/Command/Api/ListKeysCommandTest.php @@ -55,11 +55,11 @@ public static function provideKeysAndOutputs(): iterable +--------------------------------------+------+------------+---------------------------+-------+ | 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 @@ public static function provideKeysAndOutputs(): iterable +--------------------------------------+------+-----------------+-------+ | Key | Name | Expiration date | Roles | +--------------------------------------+------+-----------------+-------+ - | {$apiKey1} | - | - | Admin | + | {$apiKey1->key} | - | - | Admin | +--------------------------------------+------+-----------------+-------+ - | {$apiKey2} | - | - | Admin | + | {$apiKey2->key} | - | - | Admin | +--------------------------------------+------+-----------------+-------+ OUTPUT, @@ -97,18 +97,18 @@ public static function provideKeysAndOutputs(): iterable +--------------------------------------+------+-----------------+--------------------------+ | 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 @@ public static function provideKeysAndOutputs(): iterable +--------------------------------------+---------------+-----------------+-------+ | 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 41b3fe88d..3b84d175c 100644 --- a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php @@ -140,7 +140,7 @@ public function provideOptionalFlagsMakesNewColumnsToBeIncluded( 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 020c1f27d..04b372147 100644 --- a/module/Rest/src/ApiKey/Model/ApiKeyMeta.php +++ b/module/Rest/src/ApiKey/Model/ApiKeyMeta.php @@ -7,16 +7,16 @@ 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 1cca4f3aa..4f7575c51 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 @@ public function isValid(): bool 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 1b4f64eaa..c734e342a 100644 --- a/module/Rest/test-api/Fixtures/ApiKeyFixture.php +++ b/module/Rest/test-api/Fixtures/ApiKeyFixture.php @@ -8,7 +8,6 @@ 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 @@ public function load(ObjectManager $manager): void 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 99a8b3e64..5c5304802 100644 --- a/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php +++ b/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php @@ -130,18 +130,17 @@ public function throwsExceptionWhenProvidedApiKeyIsInvalid(): void 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 000000000..4ce5548bb --- /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 a7656189f..a6b8bad08 100644 --- a/module/CLI/src/Command/Api/GenerateKeyCommand.php +++ b/module/CLI/src/Command/Api/GenerateKeyCommand.php @@ -102,19 +102,24 @@ protected function execute(InputInterface $input, OutputInterface $output): int { $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 ab10ebc6a..d341389d1 100644 --- a/module/CLI/src/Command/Api/ListKeysCommand.php +++ b/module/CLI/src/Command/Api/ListKeysCommand.php @@ -54,7 +54,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $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 @@ protected function execute(InputInterface $input, OutputInterface $output): int }, $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 46e3c135b..9e0ce90d6 100644 --- a/module/CLI/test-cli/Command/ListApiKeysTest.php +++ b/module/CLI/test-cli/Command/ListApiKeysTest.php @@ -26,38 +26,38 @@ public static function provideFlags(): iterable { $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 a15ad6675..9c1d337ef 100644 --- a/module/CLI/test/Command/Api/GenerateKeyCommandTest.php +++ b/module/CLI/test/Command/Api/GenerateKeyCommandTest.php @@ -36,7 +36,7 @@ protected function setUp(): void 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 6f4b816bb..54ae4c3e0 100644 --- a/module/CLI/test/Command/Api/ListKeysCommandTest.php +++ b/module/CLI/test/Command/Api/ListKeysCommandTest.php @@ -52,15 +52,15 @@ public static function provideKeysAndOutputs(): iterable ], 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 @@ public static function provideKeysAndOutputs(): iterable [$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 @@ public static function provideKeysAndOutputs(): iterable ], 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 04b372147..21efe16a9 100644 --- a/module/Rest/src/ApiKey/Model/ApiKeyMeta.php +++ b/module/Rest/src/ApiKey/Model/ApiKeyMeta.php @@ -7,6 +7,9 @@ use Cake\Chronos\Chronos; use Ramsey\Uuid\Uuid; +use function sprintf; +use function substr; + final readonly class ApiKeyMeta { /** @@ -14,7 +17,7 @@ */ private function __construct( public string $key, - public string|null $name, + public string $name, public Chronos|null $expirationDate, public iterable $roleDefinitions, ) { @@ -34,8 +37,19 @@ public static function fromParams( 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 2d82b23ee..b45233711 100644 --- a/module/Rest/src/ApiKey/Repository/ApiKeyRepository.php +++ b/module/Rest/src/ApiKey/Repository/ApiKeyRepository.php @@ -15,31 +15,30 @@ 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 4f7575c51..8a72b85eb 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\RoleDefinition; use Shlinkio\Shlink\Rest\ApiKey\Role; +use function hash; + class ApiKey extends AbstractEntity { /** @@ -42,6 +44,7 @@ public static function create(): ApiKey */ 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 @@ public static function fromMeta(ApiKeyMeta $meta): self 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 4a1fc1cfc..7c4150970 100644 --- a/module/Rest/src/Service/ApiKeyCheckResult.php +++ b/module/Rest/src/Service/ApiKeyCheckResult.php @@ -6,9 +6,9 @@ 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 6c825a4a3..9b731a556 100644 --- a/module/Rest/src/Service/ApiKeyService.php +++ b/module/Rest/src/Service/ApiKeyService.php @@ -10,11 +10,9 @@ 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 @@ public function disable(string $key): ApiKey { $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 @@ public function disable(string $key): ApiKey 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 a799da279..d6f49fb64 100644 --- a/module/Rest/test/Service/ApiKeyServiceTest.php +++ b/module/Rest/test/Service/ApiKeyServiceTest.php @@ -18,6 +18,8 @@ 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 @@ public function apiKeyIsProperlyCreated(Chronos|null $date, string|null $name, a $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 900402e1e..fadc78e20 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -118,14 +118,9 @@ protected function configure(): void '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 @@ private function resolveColumnsMap(InputInterface $input): array $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 3b84d175c..0a7f9aa07 100644 --- a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php @@ -25,7 +25,6 @@ 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 @@ public function passingPageWillMakeListStartOnThatPage(): void #[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 000000000..0e9f7eff9 --- /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 8a72b85eb..32f8fff41 100644 --- a/module/Rest/src/Entity/ApiKey.php +++ b/module/Rest/src/Entity/ApiKey.php @@ -44,8 +44,7 @@ public static function create(): ApiKey */ 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 9b731a556..ca43a81f2 100644 --- a/module/Rest/src/Service/ApiKeyService.php +++ b/module/Rest/src/Service/ApiKeyService.php @@ -67,8 +67,7 @@ public function listKeys(bool $enabledOnly = false): array 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 c19d85129..62d52de6b 100644 --- a/module/Rest/test-db/ApiKey/Repository/ApiKeyRepositoryTest.php +++ b/module/Rest/test-db/ApiKey/Repository/ApiKeyRepositoryTest.php @@ -24,9 +24,9 @@ public function initialApiKeyIsCreatedOnlyOfNoApiKeysExistYet(): void 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 d6f49fb64..b081b99ae 100644 --- a/module/Rest/test/Service/ApiKeyServiceTest.php +++ b/module/Rest/test/Service/ApiKeyServiceTest.php @@ -74,7 +74,9 @@ public static function provideCreationDate(): iterable #[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 @@ public function checkReturnsTrueWhenConditionsAreFavorable(): void { $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 @@ public function checkReturnsTrueWhenConditionsAreFavorable(): void #[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 @@ public function disableThrowsExceptionWhenNoApiKeyIsFound(): void 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 21efe16a9..66d7d889b 100644 --- a/module/Rest/src/ApiKey/Model/ApiKeyMeta.php +++ b/module/Rest/src/ApiKey/Model/ApiKeyMeta.php @@ -41,7 +41,7 @@ public static function fromParams( // 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 32f8fff41..17461d12c 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 3da85e9ef..c2ed41738 100644 --- a/module/CLI/src/Command/Api/DisableKeyCommand.php +++ b/module/CLI/src/Command/Api/DisableKeyCommand.php @@ -6,39 +6,99 @@ 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 a12cb46fb..a617539d0 100644 --- a/module/CLI/test/Command/Api/DisableKeyCommandTest.php +++ b/module/CLI/test/Command/Api/DisableKeyCommandTest.php @@ -8,7 +8,10 @@ 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 @@ protected function setUp(): void 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 17461d12c..fea06a1d5 100644 --- a/module/Rest/src/Entity/ApiKey.php +++ b/module/Rest/src/Entity/ApiKey.php @@ -7,7 +7,6 @@ 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 @@ private function __construct( ) { } - /** - * @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 ca43a81f2..66cb1b189 100644 --- a/module/Rest/src/Service/ApiKeyService.php +++ b/module/Rest/src/Service/ApiKeyService.php @@ -40,11 +40,25 @@ public function check(string $key): ApiKeyCheckResult } /** - * @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 167041c5f..1fefc5f4f 100644 --- a/module/Rest/src/Service/ApiKeyServiceInterface.php +++ b/module/Rest/src/Service/ApiKeyServiceInterface.php @@ -19,7 +19,13 @@ public function check(string $key): ApiKeyCheckResult; /** * @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 b081b99ae..ba57fec4e 100644 --- a/module/Rest/test/Service/ApiKeyServiceTest.php +++ b/module/Rest/test/Service/ApiKeyServiceTest.php @@ -110,35 +110,37 @@ public function checkReturnsTrueWhenConditionsAreFavorable(): void 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 b69cf36d6..df482a460 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -9,6 +9,7 @@ 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\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 @@ '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 @@ ], 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 66cb1b189..e1a2f57af 100644 --- a/module/Rest/src/Service/ApiKeyService.php +++ b/module/Rest/src/Service/ApiKeyService.php @@ -12,7 +12,7 @@ readonly class ApiKeyService implements ApiKeyServiceInterface { - public function __construct(private EntityManagerInterface $em) + public function __construct(private EntityManagerInterface $em, private ApiKeyRepositoryInterface $repo) { } @@ -28,14 +28,12 @@ public function create(ApiKeyMeta $apiKeyMeta): ApiKey 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 @@ public function check(string $key): ApiKeyCheckResult */ 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 @@ public function disableByName(string $apiKeyName): ApiKey */ 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 @@ private function disableApiKey(ApiKey|null $apiKey): ApiKey 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 ba57fec4e..d3c62af22 100644 --- a/module/Rest/test/Service/ApiKeyServiceTest.php +++ b/module/Rest/test/Service/ApiKeyServiceTest.php @@ -14,7 +14,7 @@ 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 @@ public function checkReturnsFalseForInvalidApiKeys(ApiKey|null $invalidKey): voi $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 @@ public function checkReturnsTrueWhenConditionsAreFavorable(): void $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 @@ public function checkReturnsTrueWhenConditionsAreFavorable(): void 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 @@ public function disableReturnsDisabledApiKeyWhenFound(string $disableMethod, arr { $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 @@ public function listFindsAllApiKeys(): void $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 @@ public function listEnabledFindsOnlyEnabledApiKeys(): void $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 @@ public function listEnabledFindsOnlyEnabledApiKeys(): 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); $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 000000000..c6693c448 --- /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 04e555199..0f81dc10f 100644 --- a/module/Rest/src/ApiKey/Repository/ApiKeyRepositoryInterface.php +++ b/module/Rest/src/ApiKey/Repository/ApiKeyRepositoryInterface.php @@ -4,14 +4,14 @@ 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 e1a2f57af..4b786c6dc 100644 --- a/module/Rest/src/Service/ApiKeyService.php +++ b/module/Rest/src/Service/ApiKeyService.php @@ -42,7 +42,7 @@ public function check(string $key): ApiKeyCheckResult */ public function disableByName(string $apiKeyName): ApiKey { - return $this->disableApiKey($this->findByName($apiKeyName)); + return $this->disableApiKey($this->repo->findOneBy(['name' => $apiKeyName])); } /** @@ -79,8 +79,11 @@ private function findByKey(string $key): ApiKey|null 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 1fefc5f4f..73773fba3 100644 --- a/module/Rest/src/Service/ApiKeyServiceInterface.php +++ b/module/Rest/src/Service/ApiKeyServiceInterface.php @@ -31,4 +31,9 @@ public function disableByKey(string $key): ApiKey; * @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 d3c62af22..e304a6514 100644 --- a/module/Rest/test/Service/ApiKeyServiceTest.php +++ b/module/Rest/test/Service/ApiKeyServiceTest.php @@ -8,6 +8,7 @@ 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 @@ public static function provideInitialApiKeys(): iterable 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 a6b8bad08..3a1432acf 100644 --- a/module/CLI/src/Command/Api/GenerateKeyCommand.php +++ b/module/CLI/src/Command/Api/GenerateKeyCommand.php @@ -100,16 +100,22 @@ protected function configure(): void 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 @@ protected function execute(InputInterface $input, OutputInterface $output): int 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 9c1d337ef..10633b9ae 100644 --- a/module/CLI/test/Command/Api/GenerateKeyCommandTest.php +++ b/module/CLI/test/Command/Api/GenerateKeyCommandTest.php @@ -10,6 +10,7 @@ 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 @@ public function nameIsDefinedIfProvided(): void $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 | 39 ++++++++++-- .../src/Service/ApiKeyServiceInterface.php | 6 ++ .../test/Action/Tag/UpdateTagActionTest.php | 4 +- .../Rest/test/Service/ApiKeyServiceTest.php | 63 +++++++++++++++++++ 14 files changed, 131 insertions(+), 30 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 fdc0f0ced..5830858eb 100644 --- a/module/CLI/src/Command/Tag/RenameTagCommand.php +++ b/module/CLI/src/Command/Tag/RenameTagCommand.php @@ -7,7 +7,7 @@ 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 @@ protected function execute(InputInterface $input, OutputInterface $output): int $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 296926b8e..e7fb630d2 100644 --- a/module/CLI/test/Command/Tag/RenameTagCommandTest.php +++ b/module/CLI/test/Command/Tag/RenameTagCommandTest.php @@ -9,8 +9,8 @@ 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 @@ public function errorIsPrintedIfExceptionIsThrown(): void $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 @@ public function successIsPrintedIfNoErrorOccurs(): void $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 0fc5c317b..e05754c7c 100644 --- a/module/Core/src/Exception/TagConflictException.php +++ b/module/Core/src/Exception/TagConflictException.php @@ -7,7 +7,7 @@ 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 9c523b8b3..e4cee8700 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 e3e5b92fb..f91c018fb 100644 --- a/module/Core/src/Tag/TagService.php +++ b/module/Core/src/Tag/TagService.php @@ -10,8 +10,8 @@ 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 @@ public function deleteTags(array $tagNames, ApiKey|null $apiKey = null): void /** * @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 c09370cf6..a22e2ec80 100644 --- a/module/Core/src/Tag/TagServiceInterface.php +++ b/module/Core/src/Tag/TagServiceInterface.php @@ -8,9 +8,9 @@ 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 @@ public function deleteTags(array $tagNames, ApiKey|null $apiKey = null): void; * @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 2f4bd66a3..9126e6f3c 100644 --- a/module/Core/test/Exception/TagConflictExceptionTest.php +++ b/module/Core/test/Exception/TagConflictExceptionTest.php @@ -7,7 +7,7 @@ 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 @@ public function properlyCreatesExceptionForExistingTag(): void $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 7e82eb1cc..c1fa8ee76 100644 --- a/module/Core/test/Tag/TagServiceTest.php +++ b/module/Core/test/Tag/TagServiceTest.php @@ -13,9 +13,9 @@ 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 @@ public function renameInvalidTagThrowsException(ApiKey|null $apiKey): void $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 @@ public function renameValidTagChangesItsName(string $oldName, string $newName, i $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 @@ public function renameTagToAnExistingNameThrowsException(ApiKey|null $apiKey): v $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 @@ public function renamingTagThrowsExceptionWhenProvidedApiKeyIsNotAdmin(): void $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 016d008b4..e1dc16116 100644 --- a/module/Rest/src/Action/Tag/UpdateTagAction.php +++ b/module/Rest/src/Action/Tag/UpdateTagAction.php @@ -7,7 +7,7 @@ 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 @@ public function handle(ServerRequestInterface $request): ResponseInterface $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 fea06a1d5..c9cdd3a65 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 4b786c6dc..38ed004d8 100644 --- a/module/Rest/src/Service/ApiKeyService.php +++ b/module/Rest/src/Service/ApiKeyService.php @@ -6,10 +6,13 @@ 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,16 +77,44 @@ public function listKeys(bool $enabledOnly = false): array return $this->repo->findBy($conditions); } - private function findByKey(string $key): ApiKey|null + /** + * @inheritDoc + */ + public function existsWithName(string $apiKeyName): bool { - return $this->repo->findOneBy(['key' => ApiKey::hashKey($key)]); + return $this->repo->count(['name' => $apiKeyName]) > 0; } /** * @inheritDoc */ - public function existsWithName(string $apiKeyName): bool + public function renameApiKey(Renaming $apiKeyRenaming): ApiKey { - return $this->repo->count(['name' => $apiKeyName]) > 0; + $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 73773fba3..4197b3fd1 100644 --- a/module/Rest/src/Service/ApiKeyServiceInterface.php +++ b/module/Rest/src/Service/ApiKeyServiceInterface.php @@ -5,6 +5,7 @@ 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 @@ 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 + */ + 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 73575baf5..f83ee037e 100644 --- a/module/Rest/test/Action/Tag/UpdateTagActionTest.php +++ b/module/Rest/test/Action/Tag/UpdateTagActionTest.php @@ -11,8 +11,8 @@ 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 @@ public function correctInvocationRenamesTag(): void '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 e304a6514..f439e5f9d 100644 --- a/module/Rest/test/Service/ApiKeyServiceTest.php +++ b/module/Rest/test/Service/ApiKeyServiceTest.php @@ -13,6 +13,7 @@ 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 @@ public function existsWithNameCountsEntriesInRepository(int $count, bool $expect $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 8283a7b6d..a554db408 100644 --- a/module/CLI/config/cli.config.php +++ b/module/CLI/config/cli.config.php @@ -28,6 +28,7 @@ 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 2f098998c..76e7c4f54 100644 --- a/module/CLI/config/dependencies.config.php +++ b/module/CLI/config/dependencies.config.php @@ -59,6 +59,7 @@ 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 @@ 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 000000000..f7e24992b --- /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 38ed004d8..66876204c 100644 --- a/module/Rest/src/Service/ApiKeyService.php +++ b/module/Rest/src/Service/ApiKeyService.php @@ -87,6 +87,9 @@ public function existsWithName(string $apiKeyName): bool /** * @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 a6741b550..bd80fa591 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 3a1432acf..9fc0bb1d9 100644 --- a/module/CLI/src/Command/Api/GenerateKeyCommand.php +++ b/module/CLI/src/Command/Api/GenerateKeyCommand.php @@ -108,13 +108,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int 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 10633b9ae..1eb977bf6 100644 --- a/module/CLI/test/Command/Api/GenerateKeyCommandTest.php +++ b/module/CLI/test/Command/Api/GenerateKeyCommandTest.php @@ -71,21 +71,4 @@ public function nameIsDefinedIfProvided(): void 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 66876204c..f517dde53 100644 --- a/module/Rest/src/Service/ApiKeyService.php +++ b/module/Rest/src/Service/ApiKeyService.php @@ -21,7 +21,13 @@ public function __construct(private EntityManagerInterface $em, private ApiKeyRe 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 @@ public function listKeys(bool $enabledOnly = false): array 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 @@ 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/src/Service/ApiKeyServiceInterface.php b/module/Rest/src/Service/ApiKeyServiceInterface.php index 4197b3fd1..c42505b70 100644 --- a/module/Rest/src/Service/ApiKeyServiceInterface.php +++ b/module/Rest/src/Service/ApiKeyServiceInterface.php @@ -33,11 +33,6 @@ public function disableByKey(string $key): ApiKey; */ 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 f439e5f9d..adecfbd9f 100644 --- a/module/Rest/test/Service/ApiKeyServiceTest.php +++ b/module/Rest/test/Service/ApiKeyServiceTest.php @@ -8,7 +8,6 @@ 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 @@ protected function setUp(): void #[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 @@ public static function provideCreationDate(): iterable 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 @@ public static function provideInitialApiKeys(): iterable 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 000000000..41e5689f8 --- /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 000000000..dfd5b9f9e --- /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 552d5e2a3..67b6bff6b 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -64,6 +64,7 @@ ], Tag\TagService::class => ConfigAbstractFactory::class, + Tag\Repository\TagRepository::class => [EntityRepositoryFactory::class, Tag\Entity\Tag::class], Domain\DomainService::class => ConfigAbstractFactory::class, @@ -153,7 +154,7 @@ 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 236beb14d..b0601b3b8 100644 --- a/module/Core/src/Tag/Repository/TagRepositoryInterface.php +++ b/module/Core/src/Tag/Repository/TagRepositoryInterface.php @@ -4,15 +4,15 @@ 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 f91c018fb..3681d454e 100644 --- a/module/Core/src/Tag/TagService.php +++ b/module/Core/src/Tag/TagService.php @@ -15,13 +15,12 @@ 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 @@ public function __construct(private ORM\EntityManagerInterface $em) */ 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 @@ public function listTags(TagsParams $params, ApiKey|null $apiKey = null): Pagina */ 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 @@ public function deleteTags(array $tagNames, ApiKey|null $apiKey = null): void throw ForbiddenTagOperationException::forDeletion(); } - /** @var TagRepository $repo */ - $repo = $this->em->getRepository(Tag::class); - $repo->deleteByName($tagNames); + $this->repo->deleteByName($tagNames); } /** @@ -80,16 +73,12 @@ public function renameTag(Renaming $renaming, ApiKey|null $apiKey = null): Tag 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 c1fa8ee76..4080986fd 100644 --- a/module/Core/test/Tag/TagServiceTest.php +++ b/module/Core/test/Tag/TagServiceTest.php @@ -35,9 +35,8 @@ protected function setUp(): void { $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 @@ public function renameTagToAnExistingNameThrowsException(ApiKey|null $apiKey): v #[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 67b6bff6b..9852bdad6 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -67,6 +67,7 @@ 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 @@ 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 18d66328a..52bd6082d 100644 --- a/module/Core/src/Domain/DomainService.php +++ b/module/Core/src/Domain/DomainService.php @@ -19,8 +19,11 @@ 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 @@ public function listDomains(ApiKey|null $apiKey = null): array */ 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 @@ private function defaultDomainAndRest(ApiKey|null $apiKey): array */ 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 @@ public function getDomain(string $domainId): Domain 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 b7f78c6b2..fb601d51c 100644 --- a/module/Core/test/Domain/DomainServiceTest.php +++ b/module/Core/test/Domain/DomainServiceTest.php @@ -15,7 +15,7 @@ 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 @@ public function getDomainReturnsEntityWhenFound(): void 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 @@ public function getOrCreateThrowsExceptionForApiKeysWithDomainRole(): void $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 @@ public function configureNotFoundRedirectsConfiguresFetchedDomain( 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 9852bdad6..4afb28d51 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -50,6 +50,10 @@ 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 @@ 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 16da0a09f..266e9a7af 100644 --- a/module/Core/src/Importer/ImportedLinksProcessor.php +++ b/module/Core/src/Importer/ImportedLinksProcessor.php @@ -25,13 +25,13 @@ 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 0f32768d5..408988a5a 100644 --- a/module/Core/src/ShortUrl/ShortUrlResolver.php +++ b/module/Core/src/ShortUrl/ShortUrlResolver.php @@ -4,18 +4,17 @@ 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 @@ public function __construct( */ 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 @@ public function resolveEnabledShortUrl(ShortUrlIdentifier $identifier): ShortUrl */ 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 24571b418..4d199d671 100644 --- a/module/Core/test/ShortUrl/ShortUrlResolverTest.php +++ b/module/Core/test/ShortUrl/ShortUrlResolverTest.php @@ -6,7 +6,6 @@ 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 @@ 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 @@ public function shortCodeIsProperlyParsed(ApiKey|null $apiKey): void $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 @@ public function exceptionIsThrownIfShortCodeIsNotFound(ApiKey|null $apiKey): voi $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 @@ public function resolveEnabledShortUrlProperlyParsesShortCode(): void 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 @@ public function resolutionThrowsExceptionIfUrlIsNotEnabled(string $method): void 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 @@ public function resolveEnabledShortUrlThrowsExceptionIfUrlIsNotEnabled(ShortUrl 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 4afb28d51..7ae8ae27c 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -138,6 +138,7 @@ 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 a0692e063..2a4d75714 100644 --- a/module/Core/src/ShortUrl/UrlShortener.php +++ b/module/Core/src/ShortUrl/UrlShortener.php @@ -17,14 +17,15 @@ 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 @@ private function findExistingShortUrlIfExists(ShortUrlCreation $creation): Short 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 b332afd2c..a6cacc469 100644 --- a/module/Core/test/ShortUrl/UrlShortenerTest.php +++ b/module/Core/test/ShortUrl/UrlShortenerTest.php @@ -17,7 +17,7 @@ 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 @@ protected function setUp(): void ); $this->dispatcher = $this->createMock(EventDispatcherInterface::class); + $this->repo = $this->createMock(ShortUrlRepositoryInterface::class); $this->urlShortener = new UrlShortener( $this->titleResolutionHelper, @@ -49,6 +51,7 @@ protected function setUp(): void new SimpleShortUrlRelationResolver(), $this->shortCodeHelper, $this->dispatcher, + $this->repo, ); } @@ -102,9 +105,7 @@ public function exceptionIsThrownWhenNonUniqueSlugIsProvided(): void #[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 7ae8ae27c..ad3452e41 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -175,7 +175,10 @@ 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 7f863f6cc..7c7f2a764 100644 --- a/module/Core/src/ShortUrl/Helper/ShortCodeUniquenessHelper.php +++ b/module/Core/src/ShortUrl/Helper/ShortCodeUniquenessHelper.php @@ -4,25 +4,21 @@ 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 f341585e4..c08a95af3 100644 --- a/module/Core/test/ShortUrl/Helper/ShortCodeUniquenessHelperTest.php +++ b/module/Core/test/ShortUrl/Helper/ShortCodeUniquenessHelperTest.php @@ -4,7 +4,6 @@ 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\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 @@ public function shortCodeIsRegeneratedIfAlreadyInUse(Domain|null $domain, string { $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 @@ public static function provideDomains(): iterable #[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 f20733974..a38b9b321 100644 --- a/module/CLI/src/Input/EndDateOption.php +++ b/module/CLI/src/Input/EndDateOption.php @@ -11,7 +11,7 @@ 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 2d3bf91ea..1ff1de3f1 100644 --- a/module/CLI/src/Input/ShortUrlDataInput.php +++ b/module/CLI/src/Input/ShortUrlDataInput.php @@ -18,7 +18,7 @@ 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 def03f749..46ac79da8 100644 --- a/module/CLI/src/Input/ShortUrlIdentifierInput.php +++ b/module/CLI/src/Input/ShortUrlIdentifierInput.php @@ -10,7 +10,7 @@ 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 eaef301f3..453b31a22 100644 --- a/module/CLI/src/Input/StartDateOption.php +++ b/module/CLI/src/Input/StartDateOption.php @@ -11,7 +11,7 @@ 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 958cb96e5..12c0e546d 100644 --- a/module/Core/src/Crawling/CrawlingHelper.php +++ b/module/Core/src/Crawling/CrawlingHelper.php @@ -6,9 +6,9 @@ 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 53f2b6f70..6352e924a 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\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 de0c54607..99f71f8b5 100644 --- a/module/Core/src/ErrorHandler/Model/NotFoundType.php +++ b/module/Core/src/ErrorHandler/Model/NotFoundType.php @@ -11,9 +11,9 @@ 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 1a51e9498..3c60515b3 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\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 f19378ea0..4e4720c5e 100644 --- a/module/Core/src/EventDispatcher/UpdateGeoLiteDb.php +++ b/module/Core/src/EventDispatcher/UpdateGeoLiteDb.php @@ -13,12 +13,12 @@ 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 7c8689077..4a02f6e97 100644 --- a/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php +++ b/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php @@ -25,14 +25,14 @@ 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 6bfd91bc6..a710b63a5 100644 --- a/module/Core/src/ShortUrl/Model/UrlShorteningResult.php +++ b/module/Core/src/ShortUrl/Model/UrlShorteningResult.php @@ -7,11 +7,11 @@ 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 e8a076544..eec122a2d 100644 --- a/module/Core/src/ShortUrl/ShortUrlVisitsDeleter.php +++ b/module/Core/src/ShortUrl/ShortUrlVisitsDeleter.php @@ -10,11 +10,11 @@ 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 edb04b8e2..4c4fdd21d 100644 --- a/module/Core/src/Util/RedirectResponseHelper.php +++ b/module/Core/src/Util/RedirectResponseHelper.php @@ -10,9 +10,9 @@ 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 63cb61372..f3aba1931 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\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 9d614a7be..b444e5927 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\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 fb0f231a7..42ca0ffa7 100644 --- a/module/Core/src/Visit/VisitsDeleter.php +++ b/module/Core/src/Visit/VisitsDeleter.php @@ -9,9 +9,9 @@ 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 1100f0991..84f626b77 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 d630520b5..60955dd18 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 d90ad2569..1751aac94 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 051093656..26d2dff5a 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 62aa89e79..6529c6a9a 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 6d3d4b394..ee5843d5b 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 | 50 +++++++++---------- .../Repository/ApiKeyRepositoryTest.php | 11 ++++ .../Rest/test/Service/ApiKeyServiceTest.php | 18 ++++--- 5 files changed, 70 insertions(+), 37 deletions(-) diff --git a/module/Rest/src/ApiKey/Repository/ApiKeyRepository.php b/module/Rest/src/ApiKey/Repository/ApiKeyRepository.php index b45233711..e1fbf3a6c 100644 --- a/module/Rest/src/ApiKey/Repository/ApiKeyRepository.php +++ b/module/Rest/src/ApiKey/Repository/ApiKeyRepository.php @@ -15,7 +15,7 @@ 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 @@ public function createInitialApiKey(string $apiKey): ApiKey|null 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 0f81dc10f..32ada38a3 100644 --- a/module/Rest/src/ApiKey/Repository/ApiKeyRepositoryInterface.php +++ b/module/Rest/src/ApiKey/Repository/ApiKeyRepositoryInterface.php @@ -14,7 +14,12 @@ 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 f517dde53..19140534d 100644 --- a/module/Rest/src/Service/ApiKeyService.php +++ b/module/Rest/src/Service/ApiKeyService.php @@ -21,18 +21,20 @@ public function __construct(private EntityManagerInterface $em, private ApiKeyRe 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(); + 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(); - return $apiKey; + return $apiKey; + }); } public function createInitial(string $key): ApiKey|null @@ -85,9 +87,6 @@ public function listKeys(bool $enabledOnly = false): array /** * @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 @@ public function renameApiKey(Renaming $apiKeyRenaming): ApiKey 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 62d52de6b..d0f6157d6 100644 --- a/module/Rest/test-db/ApiKey/Repository/ApiKeyRepositoryTest.php +++ b/module/Rest/test-db/ApiKey/Repository/ApiKeyRepositoryTest.php @@ -5,6 +5,7 @@ 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 @@ public function initialApiKeyIsCreatedOnlyOfNoApiKeysExistYet(): void 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 adecfbd9f..2b3d4f96c 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 @@ protected function setUp(): void #[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 @@ public static function provideCreationDate(): iterable #[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 @@ 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->repo->expects($this->never())->method('nameExists'); $this->em->expects($this->never())->method('flush'); $this->expectException(InvalidArgumentException::class); @@ -216,7 +218,7 @@ public function renameApiKeyReturnsApiKeyVerbatimIfBothNamesAreEqual(): void $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 @@ public function renameApiKeyThrowsExceptionIfNewNameIsInUse(): void $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 @@ public function renameApiKeyReturnsApiKeyWithNewName(): void $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 66d7d889b..ae1b189cf 100644 --- a/module/Rest/src/ApiKey/Model/ApiKeyMeta.php +++ b/module/Rest/src/ApiKey/Model/ApiKeyMeta.php @@ -18,12 +18,13 @@ 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 @@ public static function fromParams( 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 @@ public static function fromParams( 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 c9cdd3a65..63bb6fc90 100644 --- a/module/Rest/src/Entity/ApiKey.php +++ b/module/Rest/src/Entity/ApiKey.php @@ -33,7 +33,7 @@ private function __construct( 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 19140534d..09d1bb760 100644 --- a/module/Rest/src/Service/ApiKeyService.php +++ b/module/Rest/src/Service/ApiKeyService.php @@ -19,17 +19,13 @@ public function __construct(private EntityManagerInterface $em, private ApiKeyRe { } + /** + * @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 @@ public function create(ApiKeyMeta $apiKeyMeta): ApiKey }); } + /** + * 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 c42505b70..be7b91915 100644 --- a/module/Rest/src/Service/ApiKeyServiceInterface.php +++ b/module/Rest/src/Service/ApiKeyServiceInterface.php @@ -11,6 +11,9 @@ 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 2b3d4f96c..ee33e109c 100644 --- a/module/Rest/test/Service/ApiKeyServiceTest.php +++ b/module/Rest/test/Service/ApiKeyServiceTest.php @@ -78,7 +78,23 @@ public static function provideCreationDate(): iterable } #[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 59c6b72f5..b17ca369f 100644 --- a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php @@ -107,7 +107,7 @@ public static function provideArgs(): iterable #[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 c914f334d..493280efc 100644 --- a/module/Core/src/Visit/Model/Visitor.php +++ b/module/Core/src/Visit/Model/Visitor.php @@ -51,7 +51,7 @@ public static function fromRequest(ServerRequestInterface $request): self ); } - 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 85085220e..1d33bbd86 100644 --- a/module/Core/src/Visit/VisitsTracker.php +++ b/module/Core/src/Visit/VisitsTracker.php @@ -21,65 +21,63 @@ public function __construct( ) { } - 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 dc6503264..da2eae841 100644 --- a/module/Core/src/Visit/VisitsTrackerInterface.php +++ b/module/Core/src/Visit/VisitsTrackerInterface.php @@ -5,15 +5,16 @@ 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 1751aac94..2e10d935b 100644 --- a/module/Core/test-db/ShortUrl/Repository/DeleteExpiredShortUrlsRepositoryTest.php +++ b/module/Core/test-db/ShortUrl/Repository/DeleteExpiredShortUrlsRepositoryTest.php @@ -92,7 +92,7 @@ private function createShortUrls(int $amountOfShortUrls, array $metadata = [], i $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 26d2dff5a..435c3e58c 100644 --- a/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php +++ b/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php @@ -74,7 +74,7 @@ public function findListProperlyFiltersResult(): void $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 @@ public function findListReturnsOnlyThoseWithoutExcludedUrls(): void '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 34210dbec..224e0c11f 100644 --- a/module/Core/test-db/Tag/Repository/TagRepositoryTest.php +++ b/module/Core/test-db/Tag/Repository/TagRepositoryTest.php @@ -79,13 +79,13 @@ public function properTagsInfoIsReturned(TagsListFiltering|null $filtering, arra $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 ad8edcd28..34eeb9f94 100644 --- a/module/Core/test-db/Visit/Listener/OrphanVisitsCountTrackerTest.php +++ b/module/Core/test-db/Visit/Listener/OrphanVisitsCountTrackerTest.php @@ -26,7 +26,7 @@ protected function setUp(): void #[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 @@ public function editsExistingEntriesWhenAlreadyExist(): void } $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 7a2a6c29e..7a4c4d183 100644 --- a/module/Core/test-db/Visit/Listener/ShortUrlVisitsCountTrackerTest.php +++ b/module/Core/test-db/Visit/Listener/ShortUrlVisitsCountTrackerTest.php @@ -30,7 +30,7 @@ public function createsNewEntriesWhenNoneExist(): void $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 @@ public function editsExistingEntriesWhenAlreadyExist(): void } $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 6529c6a9a..eedd28974 100644 --- a/module/Core/test-db/Visit/Repository/VisitDeleterRepositoryTest.php +++ b/module/Core/test-db/Visit/Repository/VisitDeleterRepositoryTest.php @@ -28,8 +28,8 @@ public function deletesExpectedShortUrlVisits(): void { $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 @@ public function deletesExpectedShortUrlVisits(): void 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 @@ public function deletesExpectedShortUrlVisits(): void #[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 ee5843d5b..60c2fbea6 100644 --- a/module/Core/test-db/Visit/Repository/VisitIterationRepositoryTest.php +++ b/module/Core/test-db/Visit/Repository/VisitIterationRepositoryTest.php @@ -37,7 +37,7 @@ public function findVisitsReturnsProperVisits(int $blockSize): void $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 393e41da6..29227b8bd 100644 --- a/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php +++ b/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php @@ -311,9 +311,9 @@ public function countVisitsReturnsExpectedResultBasedOnApiKey(): void $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 @@ public function findOrphanVisitsReturnsExpectedResult(): void $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 @@ public function countOrphanVisitsReturnsExpectedResult(): void 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 @@ public function findMostRecentOrphanVisitReturnsExpectedVisit(): void { $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 @@ private function createVisitsForShortUrl(ShortUrl $shortUrl, int $amount = 6, in $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 0dda17b05..ef776d42f 100644 --- a/module/Core/test/EventDispatcher/LocateUnlocatedVisitsTest.php +++ b/module/Core/test/EventDispatcher/LocateUnlocatedVisitsTest.php @@ -39,7 +39,7 @@ public function locatorIsCalledWhenInvoked(): void #[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 ed0ada96a..725980a1c 100644 --- a/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php +++ b/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php @@ -60,7 +60,7 @@ public function visitIsNotSentWhenItDoesNotExist(): void 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 aa21411e3..1e3dfb96a 100644 --- a/module/Core/test/EventDispatcher/Mercure/NotifyVisitToMercureTest.php +++ b/module/Core/test/EventDispatcher/Mercure/NotifyVisitToMercureTest.php @@ -61,7 +61,7 @@ public function notificationsAreNotSentWhenVisitCannotBeFound(): void 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 @@ public function notificationsAreSentWhenVisitIsFound(): void 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 @@ public function notificationsAreSentForOrphanVisits(Visit $visit): void 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 7686f4abd..2e2320381 100644 --- a/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php +++ b/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php @@ -48,7 +48,7 @@ public function visitIsProperlySerializedIntoUpdate(string $method, string $expe '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 @@ public function orphanVisitIsProperlySerializedIntoUpdate(Visit $orphanVisit): v 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 1117d5d39..267858972 100644 --- a/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php +++ b/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php @@ -90,7 +90,7 @@ public function expectedChannelsAreNotifiedBasedOnTheVisitType(Visit $visit, arr 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 @@ public function printsDebugMessageInCaseOfError(Throwable $e): void { $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 @@ public static function providePayloads(): iterable $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 @@ function (MockObject & PublishingHelperInterface $helper) use ($exactly): void { }, ]; 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 cbccffd7f..20cd786a6 100644 --- a/module/Core/test/EventDispatcher/RedisPubSub/NotifyVisitToRedisTest.php +++ b/module/Core/test/EventDispatcher/RedisPubSub/NotifyVisitToRedisTest.php @@ -53,7 +53,7 @@ public function printsDebugMessageInCaseOfError(Throwable $e): void { $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 bf568bfb6..f78d0f331 100644 --- a/module/Core/test/Matomo/MatomoVisitSenderTest.php +++ b/module/Core/test/Matomo/MatomoVisitSenderTest.php @@ -77,9 +77,9 @@ public function visitIsSentToMatomo(Visit $visit, string|null $originalIpAddress 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 @@ public function properUrlIsTracked(Visit $visit, string $expectedTrackedUrl): vo 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 @@ public static function provideUrlsToTrack(): iterable ShortUrlInputFilter::LONG_URL => 'https://shlink.io', ShortUrlInputFilter::CUSTOM_SLUG => 'bar', ]), - ), Visitor::emptyInstance()), + ), Visitor::empty()), 'http://s2.test/bar', ]; } @@ -135,7 +135,7 @@ public static function provideUrlsToTrack(): iterable 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 faddafeb0..73feece25 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 4d199d671..d565a352c 100644 --- a/module/Core/test/ShortUrl/ShortUrlResolverTest.php +++ b/module/Core/test/ShortUrl/ShortUrlResolverTest.php @@ -128,7 +128,7 @@ public static function provideDisabledShortUrls(): iterable 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 @@ public static function provideDisabledShortUrls(): iterable '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 3556c1f1c..edb47a532 100644 --- a/module/Core/test/Visit/Entity/VisitTest.php +++ b/module/Core/test/Visit/Entity/VisitTest.php @@ -55,7 +55,7 @@ public function isProperlyJsonSerializedWhenOrphan(Visit $visit, array $expected 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 f1d86f636..cd6f12da4 100644 --- a/module/Core/test/Visit/Geolocation/VisitLocatorTest.php +++ b/module/Core/test/Visit/Geolocation/VisitLocatorTest.php @@ -48,7 +48,7 @@ public function locateVisitsIteratesAndLocatesExpectedVisits( $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 @@ public function visitsWhichCannotBeLocatedAreIgnoredOrLocatedAsEmpty( 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 57926afec..1f6b7f09d 100644 --- a/module/Core/test/Visit/Geolocation/VisitToLocationHelperTest.php +++ b/module/Core/test/Visit/Geolocation/VisitToLocationHelperTest.php @@ -40,7 +40,7 @@ public function throwsExpectedErrorForNonLocatableVisit( 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 dd998f717..2dbaa25a2 100644 --- a/module/Core/test/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapterTest.php +++ b/module/Core/test/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapterTest.php @@ -53,7 +53,7 @@ public function countDelegatesToRepository(): void #[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 3e50faf0b..abad2fc03 100644 --- a/module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php +++ b/module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php @@ -53,7 +53,7 @@ public function countDelegatesToRepository(): void #[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 10c11b641..d6762c009 100644 --- a/module/Core/test/Visit/VisitsStatsHelperTest.php +++ b/module/Core/test/Visit/VisitsStatsHelperTest.php @@ -104,7 +104,7 @@ public function infoReturnsVisitsForCertainShortCode(ApiKey|null $apiKey): void $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 @@ public function visitsForTagAreReturnedAsExpected(ApiKey|null $apiKey): void $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 @@ public function visitsForNonDefaultDomainAreReturnedAsExpected(ApiKey|null $apiK $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 @@ public function visitsForDefaultDomainAreReturnedAsExpected(ApiKey|null $apiKey) $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 @@ public function visitsForDefaultDomainAreReturnedAsExpected(ApiKey|null $apiKey) #[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 @@ public function orphanVisitsAreReturnedAsExpected(): void 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 f45a27d81..bfcf2828a 100644 --- a/module/Core/test/Visit/VisitsTrackerTest.php +++ b/module/Core/test/Visit/VisitsTrackerTest.php @@ -33,43 +33,44 @@ protected function setUp(): void #[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 d5bdfef94..6892d3bdc 100644 --- a/module/Rest/test/Action/Visit/OrphanVisitsActionTest.php +++ b/module/Rest/test/Action/Visit/OrphanVisitsActionTest.php @@ -35,7 +35,7 @@ protected function setUp(): void #[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 | 15 +++++++++------ module/Core/src/Visit/RequestTrackerInterface.php | 5 +++-- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/module/Core/src/Visit/RequestTracker.php b/module/Core/src/Visit/RequestTracker.php index 02fbd94e1..cb36eee24 100644 --- a/module/Core/src/Visit/RequestTracker.php +++ b/module/Core/src/Visit/RequestTracker.php @@ -12,6 +12,7 @@ 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 @@ public function __construct(private VisitsTrackerInterface $visitsTracker, priva { } - public function trackIfApplicable(ShortUrl $shortUrl, ServerRequestInterface $request): void + public function trackIfApplicable(ShortUrl $shortUrl, ServerRequestInterface $request): Visit|null { - if ($this->shouldTrackRequest($request)) { - $this->visitsTracker->track($shortUrl, Visitor::fromRequest($request)); + if (! $this->shouldTrackRequest($request)) { + return null; } + + return $this->visitsTracker->track($shortUrl, Visitor::fromRequest($request)); } - public function trackNotFoundIfApplicable(ServerRequestInterface $request): void + public function trackNotFoundIfApplicable(ServerRequestInterface $request): Visit|null { if (! $this->shouldTrackRequest($request)) { - return; + 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 9048b07f6..4fb159b07 100644 --- a/module/Core/src/Visit/RequestTrackerInterface.php +++ b/module/Core/src/Visit/RequestTrackerInterface.php @@ -6,10 +6,11 @@ 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 e1fbf3a6c..6b282a079 100644 --- a/module/Rest/src/ApiKey/Repository/ApiKeyRepository.php +++ b/module/Rest/src/ApiKey/Repository/ApiKeyRepository.php @@ -36,7 +36,6 @@ public function createInitialApiKey(string $apiKey): ApiKey|null $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 09d1bb760..f60c2179d 100644 --- a/module/Rest/src/Service/ApiKeyService.php +++ b/module/Rest/src/Service/ApiKeyService.php @@ -27,7 +27,6 @@ public function create(ApiKeyMeta $apiKeyMeta): ApiKey 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 @@ public function renameApiKey(Renaming $apiKeyRenaming): ApiKey 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 @@ public function renameApiKey(Renaming $apiKeyRenaming): ApiKey } $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 ee33e109c..bf80ae60a 100644 --- a/module/Rest/test/Service/ApiKeyServiceTest.php +++ b/module/Rest/test/Service/ApiKeyServiceTest.php @@ -45,7 +45,6 @@ public function apiKeyIsProperlyCreated(Chronos|null $date, string|null $name, a $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 @@ public function autoGeneratedNameIsRegeneratedIfAlreadyExists(): void $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 @@ public function autoGeneratedNameIsRegeneratedIfAlreadyExists(): void 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 @@ public function renameApiKeyThrowsExceptionIfApiKeyIsNotFound(): void $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 @@ public function renameApiKeyReturnsApiKeyVerbatimIfBothNamesAreEqual(): void $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 @@ public function renameApiKeyThrowsExceptionIfNewNameIsInUse(): void $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 @@ public function renameApiKeyReturnsApiKeyWithNewName(): void $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 bd80fa591..032be60e7 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 84f626b77..88e94946b 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 b6fa50342..e3a9b180a 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 fadc78e20..fffeb1f61 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -235,7 +235,7 @@ private function resolveColumnsMap(InputInterface $input): array } 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 dea28e920..b95c68450 100644 --- a/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php +++ b/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php @@ -61,8 +61,8 @@ private function resolveRowsAndHeaders(Paginator $paginator): array '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 c1cd48f52..038d570c9 100644 --- a/module/CLI/test/GeoLite/GeolocationDbUpdaterTest.php +++ b/module/CLI/test/GeoLite/GeolocationDbUpdaterTest.php @@ -41,22 +41,24 @@ protected function setUp(): void #[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 @@ public function exceptionIsThrownWhenOlderDbDoesNotExistAndDownloadFails(): void )->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 @@ public function exceptionIsThrownWhenOlderDbIsTooOldAndDownloadFails(int $days): $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 00b220e9a..cb8c0a8ca 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 266e9a7af..e8434d4f6 100644 --- a/module/Core/src/Importer/ImportedLinksProcessor.php +++ b/module/Core/src/Importer/ImportedLinksProcessor.php @@ -8,11 +8,11 @@ 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 @@ private function resolveShortUrl( 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 @@ private function importOrphanVisits(StyleInterface $io, iterable $orphanVisits): { $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 2e5f3e154..df578387e 100644 --- a/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php +++ b/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php @@ -11,8 +11,8 @@ 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 @@ private function memoizeNewTag(string $tagName): Tag } /** - * @param array $locks + * @param array $locks */ private function lock(array &$locks, string $name): void { @@ -112,7 +112,7 @@ private function lock(array &$locks, string $name): void /** /** - * @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 4545e46c1..5f3eed114 100644 --- a/module/Core/src/Tag/Repository/TagRepository.php +++ b/module/Core/src/Tag/Repository/TagRepository.php @@ -45,7 +45,7 @@ public function deleteByName(array $names): int 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 @@ public function findTagsWithInfo(TagsListFiltering|null $filtering = null): arra ->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 3681d454e..a2cbcf2c4 100644 --- a/module/Core/src/Tag/TagService.php +++ b/module/Core/src/Tag/TagService.php @@ -47,9 +47,11 @@ public function tagsInfo(TagsParams $params, ApiKey|null $apiKey = null): Pagina */ 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 1c85fe666..9c4668c1a 100644 --- a/module/Core/src/Visit/Repository/VisitRepository.php +++ b/module/Core/src/Visit/Repository/VisitRepository.php @@ -11,7 +11,7 @@ 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 @@ private function createVisitsByShortCodeQueryBuilder( 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 f1533ddf5..412decc70 100644 --- a/module/Core/src/Visit/VisitsStatsHelper.php +++ b/module/Core/src/Visit/VisitsStatsHelper.php @@ -14,7 +14,7 @@ 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\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 @@ public function visitsForShortUrl( 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 @@ public function visitsForTag(string $tag, VisitsParams $params, ApiKey|null $api 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 @@ public function visitsForDomain(string $domain, VisitsParams $params, ApiKey|nul 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 @@ public function visitsForDomain(string $domain, VisitsParams $params, ApiKey|nul */ 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 @@ public function orphanVisits(OrphanVisitsParams $params, ApiKey|null $apiKey = n 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 0bae6bd8f..ebb53c10a 100644 --- a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php +++ b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php @@ -139,7 +139,7 @@ public function __construct(private Domain $domain) { } - 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 103c6fd0e..47aa6490d 100644 --- a/module/Core/test/RedirectRule/ShortUrlRedirectRuleServiceTest.php +++ b/module/Core/test/RedirectRule/ShortUrlRedirectRuleServiceTest.php @@ -99,8 +99,6 @@ public function setRulesForShortUrlParsesProvidedData(): void $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 b5b8e00c2..d73a1a6d8 100644 --- a/module/Core/test/ShortUrl/Helper/ShortUrlTitleResolutionHelperTest.php +++ b/module/Core/test/ShortUrl/Helper/ShortUrlTitleResolutionHelperTest.php @@ -90,8 +90,8 @@ public function dataIsReturnedAsIsWhenTitleCannotBeResolvedFromResponse(): void } #[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 74c06cd53..6c051fdab 100644 --- a/module/Rest/test/Middleware/EmptyResponseImplicitOptionsMiddlewareFactoryTest.php +++ b/module/Rest/test/Middleware/EmptyResponseImplicitOptionsMiddlewareFactoryTest.php @@ -5,7 +5,6 @@ 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 @@ protected function setUp(): void $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 72c5ea6d1..7b2a47180 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 88e94946b..76d94a078 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 924876fce..f72d1ed03 100644 --- a/module/CLI/src/RedirectRule/RedirectRuleHandler.php +++ b/module/CLI/src/RedirectRule/RedirectRuleHandler.php @@ -111,6 +111,9 @@ private function addRule(ShortUrl $shortUrl, StyleInterface $io, array $currentR 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 99f5fb9c6..affa994a2 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\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\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 @@ public static function forIpAddress(string $ipAddressPattern): self 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 @@ public function matchesRequest(ServerRequestInterface $request): bool 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 @@ static function (string $lang) use ($matchLanguage, $matchCountryCode): bool { 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 @@ private function matchesRemoteIpAddress(ServerRequestInterface $request): bool 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 @@ public function toHumanFriendly(): string $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 891a8ccc1..ed587ffa6 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 b31d1fd3b..81b69fe58 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 @@ public function matchesRemoteIpAddress(string|null $remoteIp, string $ipToMatch, 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 00074acfd..5ff6371cd 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 ed587ffa6..73f11a27b 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 c2fee661b..42520a971 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\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 @@ private static function createRedirectConditionInputFilter(): 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 18713e001..99a2167b5 100644 --- a/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php +++ b/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php @@ -117,6 +117,7 @@ public function newRulesCanBeAdded( '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 @@ public static function provideDeviceConditions(): iterable 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 81b69fe58..895b8236e 100644 --- a/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php +++ b/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php @@ -97,8 +97,11 @@ public function matchesRemoteIpAddress(string|null $remoteIp, string $ipToMatch, } #[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 78eebc051..b7ddb69a6 100644 --- a/module/Core/src/Action/AbstractTrackingAction.php +++ b/module/Core/src/Action/AbstractTrackingAction.php @@ -15,6 +15,7 @@ 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 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface 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 942cf5507..a929f290d 100644 --- a/module/Core/src/Action/RedirectAction.php +++ b/module/Core/src/Action/RedirectAction.php @@ -4,7 +4,6 @@ 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\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 affa994a2..3782f0efa 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\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 @@ private function matchesRemoteIpAddress(ServerRequestInterface $request): bool 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 4a02f6e97..2b4ac7cce 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\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 @@ private function tryToResolveRedirect( 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 895b8236e..179d35e90 100644 --- a/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php +++ b/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php @@ -10,6 +10,7 @@ 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 @@ public function matchesRemoteIpAddress(string|null $remoteIp, string $ipToMatch, #[Test, DataProvider('provideVisits')] public function matchesGeolocationCountryCode( - Location|null $location, + Location|VisitLocation|null $location, string $countryCodeToMatch, bool $expected, ): void { @@ -113,5 +114,15 @@ public static function provideVisits(): iterable 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 73f11a27b..6e6857095 100644 --- a/module/Core/src/RedirectRule/Model/RedirectConditionType.php +++ b/module/Core/src/RedirectRule/Model/RedirectConditionType.php @@ -26,6 +26,7 @@ public function isValid(string $value): bool // 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 032be60e7..c0fee24b5 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 851680203..2e35c1a4d 100644 --- a/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php +++ b/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php @@ -9,6 +9,7 @@ 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\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 @@ function () use ($shortUrl, &$currentIteration, $expectedResolveCalls): ShortUrl ); $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 e71140cb9..d0186faaf 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 @@ public function throwsWhenProvidedDataIsInvalid(array $invalidData): void ], ], ]]], '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 f096e411d..6501ef13a 100644 --- a/module/Rest/test-api/Action/SetRedirectRulesTest.php +++ b/module/Rest/test-api/Action/SetRedirectRulesTest.php @@ -96,6 +96,20 @@ public function errorIsReturnedWhenInvalidUrlIsProvided(): void ], ], ]], '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 c0fee24b5..8e9555e54 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 5ff6371cd..00f0a27bb 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 f72d1ed03..89f938334 100644 --- a/module/CLI/src/RedirectRule/RedirectRuleHandler.php +++ b/module/CLI/src/RedirectRule/RedirectRuleHandler.php @@ -113,6 +113,9 @@ private function addRule(ShortUrl $shortUrl, StyleInterface $io, array $currentR ), 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 99a2167b5..eb78da613 100644 --- a/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php +++ b/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php @@ -118,6 +118,7 @@ public function newRulesCanBeAdded( '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 @@ public static function provideDeviceConditions(): iterable 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 3782f0efa..9468d582c 100644 --- a/module/Core/src/RedirectRule/Entity/RedirectCondition.php +++ b/module/Core/src/RedirectRule/Entity/RedirectCondition.php @@ -59,6 +59,11 @@ public static function forGeolocationCountryCode(string $countryCode): self 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 @@ public function matchesRequest(ServerRequestInterface $request): bool 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 @@ private function matchesGeolocationCountryCode(ServerRequestInterface $request): 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 @@ public function toHumanFriendly(): string ), 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 6e6857095..efc314f9c 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 179d35e90..d22b632d4 100644 --- a/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php +++ b/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php @@ -97,7 +97,7 @@ public function matchesRemoteIpAddress(string|null $remoteIp, string $ipToMatch, self::assertEquals($expected, $result); } - #[Test, DataProvider('provideVisits')] + #[Test, DataProvider('provideVisitsWithCountry')] public function matchesGeolocationCountryCode( Location|VisitLocation|null $location, string $countryCodeToMatch, @@ -108,7 +108,7 @@ public function matchesGeolocationCountryCode( 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 @@ public static function provideVisits(): iterable 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 | 83 ++++++++++--------- .../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, 129 insertions(+), 85 deletions(-) diff --git a/module/CLI/src/GeoLite/GeolocationDbUpdater.php b/module/CLI/src/GeoLite/GeolocationDbUpdater.php index 2abae05b2..2a0fda3bb 100644 --- a/module/CLI/src/GeoLite/GeolocationDbUpdater.php +++ b/module/CLI/src/GeoLite/GeolocationDbUpdater.php @@ -44,7 +44,7 @@ public function checkDbUpdate( 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 6563abc06..e174a3b09 100644 --- a/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php +++ b/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php @@ -40,7 +40,7 @@ protected function setUp(): void 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 ba6735ba5..a1905e383 100644 --- a/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php @@ -93,7 +93,7 @@ public function providingInvalidDatesPrintsWarning(): void #[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 9b79f5098..08ca2cd3a 100644 --- a/module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php +++ b/module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php @@ -40,7 +40,7 @@ protected function setUp(): void 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 0462c2c0a..4ebe780f5 100644 --- a/module/CLI/test/Command/Visit/GetNonOrphanVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/GetNonOrphanVisitsCommandTest.php @@ -40,7 +40,7 @@ protected function setUp(): void 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 29914b616..33a98448f 100644 --- a/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php @@ -37,7 +37,7 @@ protected function setUp(): void #[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 b17ca369f..0f24a6034 100644 --- a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php @@ -63,8 +63,8 @@ public function expectedSetOfVisitsIsProcessedBasedOnArgs( 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 @@ public static function provideIgnoredAddresses(): iterable #[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 cb8c0a8ca..1d989c751 100644 --- a/module/Core/functions/functions.php +++ b/module/Core/functions/functions.php @@ -18,6 +18,7 @@ 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 d238bb42a..754978f94 100644 --- a/module/Core/src/Config/Options/TrackingOptions.php +++ b/module/Core/src/Config/Options/TrackingOptions.php @@ -59,4 +59,12 @@ public function queryHasDisableTrackParam(array $query): bool { 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 d02d7298d..4e3c48a8a 100644 --- a/module/Core/src/Visit/Entity/Visit.php +++ b/module/Core/src/Visit/Entity/Visit.php @@ -64,7 +64,7 @@ private static function fromVisitor( 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 493280efc..e13712e18 100644 --- a/module/Core/src/Visit/Model/Visitor.php +++ b/module/Core/src/Visit/Model/Visitor.php @@ -6,78 +6,85 @@ 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; + private function __construct( + public string $userAgent, + public string $referer, + public string|null $remoteAddress, + public string $visitedUrl, + public bool $potentialBot, + public Location|null $geolocation, + ) { + } - 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, + 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, ); - $this->potentialBot = isCrawler($userAgent); } - private function cropToLength(string $value, int $length): string + 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 80e3e318f..b88bf470b 100644 --- a/module/Core/test/EventDispatcher/LocateVisitTest.php +++ b/module/Core/test/EventDispatcher/LocateVisitTest.php @@ -72,7 +72,7 @@ public function nonExistingGeoLiteDbLogsWarning(): void { $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 @@ public function invalidAddressLogsWarning(): void { $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 @@ public function unhandledExceptionLogsError(): void { $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 @@ public static function provideNonLocatableVisits(): iterable { $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 @@ public function locatableVisitsResolveToLocation(Visit $visit, string|null $orig 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 f78d0f331..0acccd1d0 100644 --- a/module/Core/test/Matomo/MatomoVisitSenderTest.php +++ b/module/Core/test/Matomo/MatomoVisitSenderTest.php @@ -92,7 +92,7 @@ public static function provideTrackerMethods(): iterable '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 @@ public static function provideUrlsToTrack(): iterable { 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 edb47a532..db23af973 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 @@ public function addressIsAnonymizedWhenRequested( ): 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 1f6b7f09d..a9d8f3e5c 100644 --- a/module/Core/test/Visit/Geolocation/VisitToLocationHelperTest.php +++ b/module/Core/test/Visit/Geolocation/VisitToLocationHelperTest.php @@ -42,7 +42,7 @@ public static function provideNonLocatableVisits(): iterable { 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 @@ public function throwsGenericErrorWhenResolvingIpFails(): void $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 04e57179a..25be7440d 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 @@ private static function generateRandomString(int $length): string #[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 9972e3a87..e10b6dabb 100644 --- a/module/Rest/test-api/Fixtures/VisitsFixture.php +++ b/module/Rest/test-api/Fixtures/VisitsFixture.php @@ -23,43 +23,55 @@ public function load(ObjectManager $manager): void { /** @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 99f71bce4..63d19d4da 100644 --- a/config/autoload/middleware-pipeline.global.php +++ b/config/autoload/middleware-pipeline.global.php @@ -11,6 +11,7 @@ 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 @@ ], '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 da3d17784..1f5425b57 100644 --- a/config/autoload/routes.config.php +++ b/config/autoload/routes.config.php @@ -8,6 +8,7 @@ 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 @@ 'path' => '/{shortCode}/track', 'middleware' => [ IpAddress::class, + IpGeolocationMiddleware::class, CoreAction\PixelAction::class, ], 'allowed_methods' => [RequestMethodInterface::METHOD_GET], @@ -105,6 +107,7 @@ '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 ad3452e41..4844e6d5c 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -11,6 +11,7 @@ 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 @@ 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 @@ 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 2491d6064..4e130fcf9 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\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 @@ // 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 @@ '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 @@ ], 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 b7ddb69a6..78eebc051 100644 --- a/module/Core/src/Action/AbstractTrackingAction.php +++ b/module/Core/src/Action/AbstractTrackingAction.php @@ -15,7 +15,6 @@ 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 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface 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 3ec9417cc..e871588f2 100644 --- a/module/Core/src/EventDispatcher/Async/AbstractNotifyVisitListener.php +++ b/module/Core/src/EventDispatcher/Async/AbstractNotifyVisitListener.php @@ -8,7 +8,7 @@ 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 @@ public function __construct( ) { } - 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 c1fa440aa..000000000 --- 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 b6ab1a0c1..4055935f6 100644 --- a/module/Core/src/EventDispatcher/Event/ShortUrlCreated.php +++ b/module/Core/src/EventDispatcher/Event/ShortUrlCreated.php @@ -7,9 +7,9 @@ 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 d1158a4ed..0d25b1a1e 100644 --- a/module/Core/src/EventDispatcher/Event/UrlVisited.php +++ b/module/Core/src/EventDispatcher/Event/UrlVisited.php @@ -4,6 +4,24 @@ 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 99b7a05ed..000000000 --- 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 5a85aed43..c7b5bd3c8 100644 --- a/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php +++ b/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php @@ -6,7 +6,7 @@ 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 @@ public function __construct( ) { } - 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 000000000..4e2e533b0 --- /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 2b4ac7cce..4a02f6e97 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\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 @@ private function tryToResolveRedirect( 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 4e3c48a8a..e26d5f802 100644 --- a/module/Core/src/Visit/Entity/Visit.php +++ b/module/Core/src/Visit/Entity/Visit.php @@ -59,6 +59,7 @@ private static function fromVisitor( Visitor $visitor, bool $anonymize, ): self { + $geolocation = $visitor->geolocation; return new self( shortUrl: $shortUrl, type: $type, @@ -67,6 +68,7 @@ private static function fromVisitor( 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 b88bf470b..000000000 --- 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 725980a1c..8a4c1b7d1 100644 --- a/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php +++ b/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php @@ -11,7 +11,7 @@ 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 @@ public function visitIsNotSentWhenMatomoIsDisabled(): void $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 @@ public function visitIsNotSentWhenItDoesNotExist(): void ['visitId' => '123'], ); - ($this->listener())(new VisitLocated('123')); + ($this->listener())(new UrlVisited('123')); } #[Test, DataProvider('provideOriginalIpAddress')] @@ -67,7 +67,7 @@ public function visitIsSentWhenItExists(string|null $originalIpAddress): void $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 @@ public function logsErrorWhenTrackingFails(): void ['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 1e3dfb96a..91569c9b7 100644 --- a/module/Core/test/EventDispatcher/Mercure/NotifyVisitToMercureTest.php +++ b/module/Core/test/EventDispatcher/Mercure/NotifyVisitToMercureTest.php @@ -13,7 +13,7 @@ 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 @@ public function notificationsAreNotSentWhenVisitCannotBeFound(): void $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 @@ public function notificationsAreSentWhenVisitIsFound(): void $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 @@ public function debugIsLoggedWhenExceptionIsThrown(): void $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 @@ public function notificationsAreSentForOrphanVisits(Visit $visit): void $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 267858972..a3cebaa7d 100644 --- a/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php +++ b/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php @@ -17,7 +17,7 @@ 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 @@ public function doesNothingWhenTheFeatureIsNotEnabled(): void $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 @@ public function notificationsAreNotSentWhenVisitCannotBeFound(): void $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 @@ public function expectedChannelsAreNotifiedBasedOnTheVisitType(Visit $visit, arr ); $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 @@ public function printsDebugMessageInCaseOfError(Throwable $e): void ['e' => $e, 'name' => 'RabbitMQ'], ); - ($this->listener())(new VisitLocated($visitId)); + ($this->listener())(new UrlVisited($visitId)); } public static function provideExceptions(): iterable @@ -142,7 +142,7 @@ public function expectedPayloadIsPublishedDependingOnConfig( $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 20cd786a6..7cbf68b6a 100644 --- a/module/Core/test/EventDispatcher/RedisPubSub/NotifyVisitToRedisTest.php +++ b/module/Core/test/EventDispatcher/RedisPubSub/NotifyVisitToRedisTest.php @@ -15,7 +15,7 @@ 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 @@ public function doesNothingWhenTheFeatureIsNotEnabled(): void $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 @@ public function printsDebugMessageInCaseOfError(Throwable $e): void ['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 2e35c1a4d..851680203 100644 --- a/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php +++ b/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php @@ -9,7 +9,6 @@ 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\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 @@ function () use ($shortUrl, &$currentIteration, $expectedResolveCalls): ShortUrl ); $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 1d989c751..6ccc42e24 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 9468d582c..cf1e134b7 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\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 @@ private function matchesRemoteIpAddress(ServerRequestInterface $request): bool 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 @@ private function matchesGeolocationCountryCode(ServerRequestInterface $request): 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 d22b632d4..2ae5df186 100644 --- a/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php +++ b/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php @@ -10,7 +10,6 @@ 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 @@ public function matchesRemoteIpAddress(string|null $remoteIp, string $ipToMatch, #[Test, DataProvider('provideVisitsWithCountry')] public function matchesGeolocationCountryCode( - Location|VisitLocation|null $location, + Location|null $location, string $countryCodeToMatch, bool $expected, ): void { @@ -114,21 +113,11 @@ public static function provideVisitsWithCountry(): iterable 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 @@ public static function provideVisitsWithCity(): iterable 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 000000000..f203fb855 --- /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 33bf8f887..010c635f8 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 70fe80494..c26aaaca4 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 37ec30dfe..3a6a86423 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 000000000..a1ffce036 --- /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 @@ public function disableByName(string $apiKeyName): ApiKey */ 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 @@ 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), - ); + throw ApiKeyNotFoundException::forName($apiKeyRenaming->oldName); } if (! $apiKeyRenaming->nameChanged()) { @@ -121,9 +127,7 @@ public function renameApiKey(Renaming $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), - ); + 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 be7b91915..745355d78 100644 --- a/module/Rest/src/Service/ApiKeyServiceInterface.php +++ b/module/Rest/src/Service/ApiKeyServiceInterface.php @@ -8,6 +8,8 @@ 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 @@ public function createInitial(string $key): ApiKey|null; 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 @@ public function disableByKey(string $key): ApiKey; 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 bf80ae60a..81bec4ea8 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\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 @@ public function disableThrowsExceptionWhenNoApiKeyIsFound(string $disableMethod, { $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 @@ public function renameApiKeyThrowsExceptionIfApiKeyIsNotFound(): void $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 @@ public function renameApiKeyThrowsExceptionIfNewNameIsInUse(): void $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 4e2e533b0..f5657e648 100644 --- a/module/Core/src/Geolocation/Middleware/IpGeolocationMiddleware.php +++ b/module/Core/src/Geolocation/Middleware/IpGeolocationMiddleware.php @@ -9,6 +9,7 @@ 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 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface 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 f3aba1931..8f69ba2c5 100644 --- a/module/Core/src/Visit/Geolocation/VisitLocator.php +++ b/module/Core/src/Visit/Geolocation/VisitLocator.php @@ -54,7 +54,7 @@ private function locateVisits(iterable $results, VisitGeolocationHelperInterface } // 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 f203fb855..80768f5bf 100644 --- a/module/Core/test/Geolocation/Middleware/IpGeolocationMiddlewareTest.php +++ b/module/Core/test/Geolocation/Middleware/IpGeolocationMiddlewareTest.php @@ -15,6 +15,7 @@ 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 @@ public function warningIsLoggedIfGeoLiteDbDoesNotExist(): void } #[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 e26d5f802..9e8540bc5 100644 --- a/module/Core/src/Visit/Entity/Visit.php +++ b/module/Core/src/Visit/Entity/Visit.php @@ -127,11 +127,6 @@ public function getVisitLocation(): VisitLocation|null 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 76d94a078..b680207d5 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 b680207d5..c880ac367 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 0e6afedc8..6991928de 100644 --- a/module/Core/src/Visit/Model/OrphanVisitsParams.php +++ b/module/Core/src/Visit/Model/OrphanVisitsParams.php @@ -21,9 +21,8 @@ public function __construct( 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 abad2fc03..b62fa0c60 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 000000000..090d30787 --- /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 fc9cf20c5..ee1625e00 100644 --- a/module/Rest/src/Action/Visit/DomainVisitsAction.php +++ b/module/Rest/src/Action/Visit/DomainVisitsAction.php @@ -4,36 +4,29 @@ 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 1fffdb8b5..8406b515b 100644 --- a/module/Rest/src/Action/Visit/NonOrphanVisitsAction.php +++ b/module/Rest/src/Action/Visit/NonOrphanVisitsAction.php @@ -4,30 +4,20 @@ 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 7906fdaec..341524c35 100644 --- a/module/Rest/src/Action/Visit/OrphanVisitsAction.php +++ b/module/Rest/src/Action/Visit/OrphanVisitsAction.php @@ -4,30 +4,22 @@ 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 fe5099a2e..95ac42cc6 100644 --- a/module/Rest/src/Action/Visit/ShortUrlVisitsAction.php +++ b/module/Rest/src/Action/Visit/ShortUrlVisitsAction.php @@ -4,32 +4,19 @@ 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 1739264fc..08553ec53 100644 --- a/module/Rest/src/Action/Visit/TagVisitsAction.php +++ b/module/Rest/src/Action/Visit/TagVisitsAction.php @@ -4,31 +4,18 @@ 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 c880ac367..6cc937387 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 a49b3d930..000000000 --- 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 000000000..9d531040d --- /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 20c64f192..d6bb9621e 100644 --- a/config/constants.php +++ b/config/constants.php @@ -21,3 +21,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'; diff --git a/module/Core/functions/functions.php b/module/Core/functions/functions.php index 6ccc42e24..513e885dc 100644 --- a/module/Core/functions/functions.php +++ b/module/Core/functions/functions.php @@ -15,7 +15,6 @@ 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 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 36031da8e..79a13fbf4 100644 --- a/module/Core/test-api/Action/RedirectTest.php +++ b/module/Core/test-api/Action/RedirectTest.php @@ -89,8 +89,8 @@ public static function provideRequestOptions(): iterable '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 80768f5bf..210fb46fa 100644 --- a/module/Core/test/Geolocation/Middleware/IpGeolocationMiddlewareTest.php +++ b/module/Core/test/Geolocation/Middleware/IpGeolocationMiddlewareTest.php @@ -14,7 +14,6 @@ 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\Resolver\IpLocationResolverInterface; use Throwable; +use const Shlinkio\Shlink\IP_ADDRESS_REQUEST_ATTRIBUTE; + class IpGeolocationMiddlewareTest extends TestCase { private MockObject & IpLocationResolverInterface $ipLocationResolver; @@ -76,10 +77,7 @@ public function emptyLocationIsReturnedIfIpAddressIsNotLocatable(string|null $ip $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 @@ public function locationIsResolvedFromIpAddress(): void ); $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 @@ public function warningIsPrintedIfAnErrorOccurs( ->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 2ae5df186..5a4a2e2be 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\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 @@ public function matchesRemoteIpAddress(string|null $remoteIp, string $ipToMatch, { $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 f26627c60..470ff95e7 100644 --- a/module/Core/test/RedirectRule/ShortUrlRedirectionResolverTest.php +++ b/module/Core/test/RedirectRule/ShortUrlRedirectionResolverTest.php @@ -9,7 +9,6 @@ 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\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 @@ public static function provideData(): iterable '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 ae0a74c4e..f9357c6ac 100644 --- a/module/Core/test/Visit/RequestTrackerTest.php +++ b/module/Core/test/Visit/RequestTrackerTest.php @@ -12,7 +12,6 @@ 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\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 @@ public static function provideNonTrackingRequests(): iterable 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 @@ public function trackingHappensOverShortUrlsWhenRemoteAddressIsInvalid(): void ); $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 6cc937387..2515e9f24 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 2515e9f24..36be20b21 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 36be20b21..6b73d9035 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 a81d51fb4..443d34a9f 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 a810fdc17..f3281630d 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 e6e94734e..4f3d1ca65 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 eb3fade1c..8e7f931fe 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 4a7904bf1..e594664b4 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 0bf251f66..198a6867f 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 b2da154b1..09817a99c 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 c4589bb18..826ad1ace 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 7d4023847..34d985724 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 @@ ->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 000000000..49c5eb059 --- /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 9e8540bc5..033d451b6 100644 --- a/module/Core/src/Visit/Entity/Visit.php +++ b/module/Core/src/Visit/Entity/Visit.php @@ -28,6 +28,7 @@ private function __construct( 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 @@ private static function fromVisitor( 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 @@ public function jsonSerialize(): array '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 e13712e18..b33d10a1b 100644 --- a/module/Core/src/Visit/Model/Visitor.php +++ b/module/Core/src/Visit/Model/Visitor.php @@ -19,6 +19,7 @@ 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 @@ private function __construct( public string $visitedUrl, public bool $potentialBot, public Location|null $geolocation, + public string $redirectUrl, ) { } @@ -36,6 +38,7 @@ public static function fromParams( 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 @@ public static function fromParams( 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 @@ public static function fromRequest(ServerRequestInterface $request): self remoteAddress: ipAddressFromRequest($request), visitedUrl: $request->getUri()->__toString(), geolocation: geolocationFromRequest($request), + // TODO + redirectUrl: '', ); } @@ -85,6 +91,7 @@ public function normalizeForTrackingOptions(TrackingOptions $options): self // 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 2e2320381..310c8b3f1 100644 --- a/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php +++ b/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php @@ -80,6 +80,7 @@ public function visitIsProperlySerializedIntoUpdate(string $method, string $expe 'date' => $visit->date->toAtomString(), 'potentialBot' => false, 'visitedUrl' => '', + 'redirectUrl' => null, ], ], $update->payload); } @@ -105,6 +106,7 @@ public function orphanVisitIsProperlySerializedIntoUpdate(Visit $orphanVisit): v '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 db23af973..438ca55f1 100644 --- a/module/Core/test/Visit/Entity/VisitTest.php +++ b/module/Core/test/Visit/Entity/VisitTest.php @@ -34,6 +34,7 @@ public function isProperlyJsonSerialized(string $userAgent, bool $expectedToBePo 'visitLocation' => null, 'potentialBot' => $expectedToBePotentialBot, 'visitedUrl' => $visit->visitedUrl, + 'redirectUrl' => $visit->redirectUrl, ], $visit->jsonSerialize()); } @@ -67,6 +68,7 @@ public static function provideOrphanVisits(): iterable 'potentialBot' => false, 'visitedUrl' => '', 'type' => VisitType::BASE_URL->value, + 'redirectUrl' => null, ], ]; yield 'invalid short url visit' => [ @@ -83,6 +85,7 @@ public static function provideOrphanVisits(): iterable 'potentialBot' => false, 'visitedUrl' => 'https://example.com/foo', 'type' => VisitType::INVALID_SHORT_URL->value, + 'redirectUrl' => null, ], ]; yield 'regular 404 visit' => [ @@ -101,6 +104,7 @@ public static function provideOrphanVisits(): iterable '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 cf7cee0f8..3761e113d 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 49c5eb059..c11cbe2bd 100644 --- a/module/Core/migrations/Version20241124112257.php +++ b/module/Core/migrations/Version20241124112257.php @@ -18,7 +18,7 @@ public function up(Schema $schema): void $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 d6bb9621e..09df0e606 100644 --- a/config/constants.php +++ b/config/constants.php @@ -22,3 +22,4 @@ 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 78eebc051..ff35828f3 100644 --- a/module/Core/src/Action/AbstractTrackingAction.php +++ b/module/Core/src/Action/AbstractTrackingAction.php @@ -16,6 +16,8 @@ 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 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface 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 f3342c5ae..633d83db6 100644 --- a/module/Core/src/ErrorHandler/NotFoundTrackerMiddleware.php +++ b/module/Core/src/ErrorHandler/NotFoundTrackerMiddleware.php @@ -10,7 +10,9 @@ 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 @@ public function __construct(private RequestTrackerInterface $requestTracker) 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 4a02f6e97..4b013b330 100644 --- a/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php +++ b/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php @@ -25,6 +25,8 @@ 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 @@ private function tryToResolveRedirect( 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 033d451b6..70733593f 100644 --- a/module/Core/src/Visit/Entity/Visit.php +++ b/module/Core/src/Visit/Entity/Visit.php @@ -69,7 +69,7 @@ private static function fromVisitor( 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 b33d10a1b..53504d75f 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\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 @@ private function __construct( public string $visitedUrl, public bool $potentialBot, public Location|null $geolocation, - public string $redirectUrl, + public string|null $redirectUrl, ) { } @@ -38,7 +39,7 @@ public static function fromParams( 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 @@ public static function fromParams( 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 @@ public static function fromRequest(ServerRequestInterface $request): self 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 53504d75f..cab834e6a 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\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 d6f2566af..e78df1771 100644 --- a/module/Core/test/Action/PixelActionTest.php +++ b/module/Core/test/Action/PixelActionTest.php @@ -16,6 +16,8 @@ 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 @@ protected function setUp(): void 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 2364371c2..fa4a561d0 100644 --- a/module/Core/test/Action/RedirectActionTest.php +++ b/module/Core/test/Action/RedirectActionTest.php @@ -19,6 +19,8 @@ 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 @@ public function redirectionIsPerformedToLongUrl(): void { $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 4558197b3..9df12a6d1 100644 --- a/module/Core/test/ErrorHandler/NotFoundTrackerMiddlewareTest.php +++ b/module/Core/test/ErrorHandler/NotFoundTrackerMiddlewareTest.php @@ -4,7 +4,9 @@ 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\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 @@ protected function setUp(): void ); } - #[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), + ); + + $result = $this->middleware->process($this->request, $this->handler); - $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 851680203..84ceb790e 100644 --- a/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php +++ b/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php @@ -30,6 +30,8 @@ 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 @@ function () use ($shortUrl, &$currentIteration, $expectedResolveCalls): ShortUrl $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 09817a99c..2d69084b4 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 826ad1ace..2ccdfd231 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 f3281630d..fee93cf1e 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 6b73d9035..6656c8eac 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 fee93cf1e..be4058377 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.