From 506ed6207fa1ced1d9b58bfab77e513a9f56eff8 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 21 Oct 2025 12:25:06 +0200 Subject: [PATCH] Allow filtering short URLs by API key name --- .../src/ShortUrl/Model/ShortUrlsParams.php | 31 +++++---- .../Validation/ShortUrlsParamsInputFilter.php | 2 + .../Persistence/ShortUrlsCountFiltering.php | 7 ++ .../Persistence/ShortUrlsListFiltering.php | 3 + .../Repository/ShortUrlListRepository.php | 10 ++- .../Repository/ShortUrlListRepositoryTest.php | 69 +++++++++++++++++++ 6 files changed, 107 insertions(+), 15 deletions(-) diff --git a/module/Core/src/ShortUrl/Model/ShortUrlsParams.php b/module/Core/src/ShortUrl/Model/ShortUrlsParams.php index 5b84ee10..b8b34904 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlsParams.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlsParams.php @@ -12,23 +12,27 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlsParamsInputFilter; use function Shlinkio\Shlink\Common\buildDateRange; use function Shlinkio\Shlink\Core\normalizeOptionalDate; -final class ShortUrlsParams +/** + * Represents all the params that can be used to filter a list of short URLs + */ +final readonly class ShortUrlsParams { public const int DEFAULT_ITEMS_PER_PAGE = 10; private function __construct( - public readonly int $page, - public readonly int $itemsPerPage, - public readonly string|null $searchTerm, - public readonly array $tags, - public readonly Ordering $orderBy, - public readonly DateRange|null $dateRange, - public readonly bool $excludeMaxVisitsReached, - public readonly bool $excludePastValidUntil, - public readonly TagsMode $tagsMode = TagsMode::ANY, - public readonly string|null $domain = null, - public readonly array $excludeTags = [], - public readonly TagsMode $excludeTagsMode = TagsMode::ANY, + public int $page, + public int $itemsPerPage, + public string|null $searchTerm, + public array $tags, + public Ordering $orderBy, + public DateRange|null $dateRange, + public bool $excludeMaxVisitsReached, + public bool $excludePastValidUntil, + public TagsMode $tagsMode = TagsMode::ANY, + public string|null $domain = null, + public array $excludeTags = [], + public TagsMode $excludeTagsMode = TagsMode::ANY, + public string|null $apiKeyName = null, ) { } @@ -67,6 +71,7 @@ final class ShortUrlsParams excludeTagsMode: self::resolveTagsMode( $inputFilter->getValue(ShortUrlsParamsInputFilter::EXCLUDE_TAGS_MODE), ), + apiKeyName: $inputFilter->getValue(ShortUrlsParamsInputFilter::API_KEY_NAME), ); } diff --git a/module/Core/src/ShortUrl/Model/Validation/ShortUrlsParamsInputFilter.php b/module/Core/src/ShortUrl/Model/Validation/ShortUrlsParamsInputFilter.php index 7d29607c..73c54e3a 100644 --- a/module/Core/src/ShortUrl/Model/Validation/ShortUrlsParamsInputFilter.php +++ b/module/Core/src/ShortUrl/Model/Validation/ShortUrlsParamsInputFilter.php @@ -30,6 +30,7 @@ class ShortUrlsParamsInputFilter extends InputFilter public const string EXCLUDE_MAX_VISITS_REACHED = 'excludeMaxVisitsReached'; public const string EXCLUDE_PAST_VALID_UNTIL = 'excludePastValidUntil'; public const string DOMAIN = 'domain'; + public const string API_KEY_NAME = 'apiKeyName'; public function __construct(array $data) { @@ -59,6 +60,7 @@ class ShortUrlsParamsInputFilter extends InputFilter $this->add(InputFactory::boolean(self::EXCLUDE_PAST_VALID_UNTIL)); $this->add(InputFactory::basic(self::DOMAIN)); + $this->add(InputFactory::basic(self::API_KEY_NAME)); } private function createTagsModeInput(string $name): Input diff --git a/module/Core/src/ShortUrl/Persistence/ShortUrlsCountFiltering.php b/module/Core/src/ShortUrl/Persistence/ShortUrlsCountFiltering.php index d793c314..8fa44408 100644 --- a/module/Core/src/ShortUrl/Persistence/ShortUrlsCountFiltering.php +++ b/module/Core/src/ShortUrl/Persistence/ShortUrlsCountFiltering.php @@ -15,6 +15,7 @@ use function strtolower; class ShortUrlsCountFiltering { public readonly bool $searchIncludesDefaultDomain; + public readonly string|null $apiKeyName; /** * @param $defaultDomain - Used only to determine if search term includes default domain @@ -31,11 +32,16 @@ class ShortUrlsCountFiltering public readonly string|null $domain = null, public readonly array $excludeTags = [], public readonly TagsMode $excludeTagsMode = TagsMode::ANY, + string|null $apiKeyName = null, ) { $this->searchIncludesDefaultDomain = !empty($searchTerm) && !empty($defaultDomain) && str_contains( strtolower($defaultDomain), strtolower($searchTerm), ); + + // Filtering by API key name is only allowed if the API key used in the request is an admin one, or it matches + // the API key name + $this->apiKeyName = $apiKey?->name === $apiKeyName || ApiKey::isAdmin($apiKey) ? $apiKeyName : null; } public static function fromParams(ShortUrlsParams $params, ApiKey|null $apiKey, string $defaultDomain): self @@ -52,6 +58,7 @@ class ShortUrlsCountFiltering $params->domain, $params->excludeTags, $params->excludeTagsMode, + $params->apiKeyName, ); } } diff --git a/module/Core/src/ShortUrl/Persistence/ShortUrlsListFiltering.php b/module/Core/src/ShortUrl/Persistence/ShortUrlsListFiltering.php index f62f59d5..f9350389 100644 --- a/module/Core/src/ShortUrl/Persistence/ShortUrlsListFiltering.php +++ b/module/Core/src/ShortUrl/Persistence/ShortUrlsListFiltering.php @@ -30,6 +30,7 @@ class ShortUrlsListFiltering extends ShortUrlsCountFiltering string|null $domain = null, array $excludeTags = [], TagsMode $excludeTagsMode = TagsMode::ANY, + string|null $apiKeyName = null, ) { parent::__construct( $searchTerm, @@ -43,6 +44,7 @@ class ShortUrlsListFiltering extends ShortUrlsCountFiltering $domain, $excludeTags, $excludeTagsMode, + $apiKeyName, ); } @@ -68,6 +70,7 @@ class ShortUrlsListFiltering extends ShortUrlsCountFiltering $params->domain, $params->excludeTags, $params->excludeTagsMode, + $params->apiKeyName, ); } } diff --git a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php index fcf80c6a..266d19e1 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php @@ -137,7 +137,6 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh ->setParameter('searchPattern', '%' . $searchTerm . '%'); } - // Filter by tags if provided if (! empty($tags)) { if ($tagsMode === TagsMode::ANY) { $qb->join('s.tags', 't')->andWhere($qb->expr()->in('t.name', $tags)); @@ -146,7 +145,6 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh } } - // Filter by excludeTags if provided if (! empty($excludeTags)) { $subQb = $this->getEntityManager()->createQueryBuilder(); $subQb->select('s2.id') @@ -192,6 +190,14 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh ->setParameter('minValidUntil', Chronos::now()->toDateTimeString()); } + $apiKeyName = $filtering->apiKeyName; + if ($apiKeyName !== null) { + $qb + ->join('s.authorApiKey', 'a') + ->andWhere($qb->expr()->eq('a.name', ':apiKeyName')) + ->setParameter('apiKeyName', $apiKeyName); + } + $this->applySpecification($qb, $filtering->apiKey?->spec(), 's'); return $qb; diff --git a/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php b/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php index ff300ee3..23f21171 100644 --- a/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php +++ b/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php @@ -22,6 +22,9 @@ use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlListRepository; use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Model\Visitor; +use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; +use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; +use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase; use function array_map; @@ -367,4 +370,70 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase excludePastValidUntil: true, ))); } + + #[Test] + public function filteringByApiKeyNameIsPossible(): void + { + $apiKey1 = ApiKey::create(); + $this->getEntityManager()->persist($apiKey1); + $apiKey2 = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls())); + $this->getEntityManager()->persist($apiKey2); + $apiKey3 = ApiKey::create(); + $this->getEntityManager()->persist($apiKey3); + + $shortUrl1 = ShortUrl::create(ShortUrlCreation::fromRawData([ + 'longUrl' => 'https://foo1', + 'apiKey' => $apiKey1, + ]), $this->relationResolver); + $this->getEntityManager()->persist($shortUrl1); + $shortUrl2 = ShortUrl::create(ShortUrlCreation::fromRawData([ + 'longUrl' => 'https://foo2', + 'apiKey' => $apiKey1, + ]), $this->relationResolver); + $this->getEntityManager()->persist($shortUrl2); + $shortUrl3 = ShortUrl::create(ShortUrlCreation::fromRawData([ + 'longUrl' => 'https://foo3', + 'apiKey' => $apiKey2, + ]), $this->relationResolver); + $this->getEntityManager()->persist($shortUrl3); + $shortUrl4 = ShortUrl::create(ShortUrlCreation::fromRawData([ + 'longUrl' => 'https://foo4', + 'apiKey' => $apiKey1, + ]), $this->relationResolver); + $this->getEntityManager()->persist($shortUrl4); + + $this->getEntityManager()->flush(); + + // It is possible to filter by API key name when no API key or ADMIN API key is provided + self::assertCount(3, $this->repo->findList(new ShortUrlsListFiltering(apiKeyName: $apiKey1->name))); + self::assertCount(1, $this->repo->findList(new ShortUrlsListFiltering(apiKeyName: $apiKey2->name))); + self::assertCount(0, $this->repo->findList(new ShortUrlsListFiltering(apiKeyName: $apiKey3->name))); + + self::assertCount(3, $this->repo->findList(new ShortUrlsListFiltering( + apiKey: $apiKey1, + apiKeyName: $apiKey1->name, + ))); + self::assertCount(1, $this->repo->findList(new ShortUrlsListFiltering( + apiKey: $apiKey1, + apiKeyName: $apiKey2->name, + ))); + self::assertCount(0, $this->repo->findList(new ShortUrlsListFiltering( + apiKey: $apiKey1, + apiKeyName: $apiKey3->name, + ))); + + // When a non-admin API key is passed, it allows to filter by itself only + self::assertCount(1, $this->repo->findList(new ShortUrlsListFiltering( + apiKey: $apiKey2, + apiKeyName: $apiKey1->name, // Ignored. Only API key 2 results are returned + ))); + self::assertCount(1, $this->repo->findList(new ShortUrlsListFiltering( + apiKey: $apiKey2, + apiKeyName: $apiKey2->name, + ))); + self::assertCount(1, $this->repo->findList(new ShortUrlsListFiltering( + apiKey: $apiKey2, + apiKeyName: $apiKey3->name, // Ignored. Only API key 2 results are returned + ))); + } }