From 506ed6207fa1ced1d9b58bfab77e513a9f56eff8 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 21 Oct 2025 12:25:06 +0200 Subject: [PATCH 1/4] 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 + ))); + } } From 7860225c25aaf8e95162dc5687c066a21be77e29 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 22 Oct 2025 08:04:29 +0200 Subject: [PATCH 2/4] Add api-key-name option to short-url:list command --- .../Command/ShortUrl/ListShortUrlsCommand.php | 37 +++++++++---------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index cea263f6..d850e831 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -109,6 +109,12 @@ class ListShortUrlsCommand extends Command 'The field from which you want to order by. ' . 'Define ordering dir by passing ASC or DESC after "-" or ",".', ) + ->addOption( + 'api-key-name', + 'kn', + InputOption::VALUE_REQUIRED, + 'List only short URLs created by the API key matching provided name.', + ) ->addOption( 'show-tags', null, @@ -142,41 +148,32 @@ class ListShortUrlsCommand extends Command $io = new SymfonyStyle($input, $output); $page = (int) $input->getOption('page'); - $searchTerm = $input->getOption('search-term'); - $domain = $input->getOption('domain'); - - $tags = $this->tagsOption->get($input); $tagsMode = $input->getOption('tags-all') === true || $input->getOption('including-all-tags') === true ? TagsMode::ALL->value : TagsMode::ANY->value; - - $excludeTags = $input->getOption('exclude-tag'); $excludeTagsMode = $input->getOption('exclude-tags-all') === true ? TagsMode::ALL->value : TagsMode::ANY->value; - $all = $input->getOption('all'); - $startDate = $this->startDateOption->get($input, $output); - $endDate = $this->endDateOption->get($input, $output); - $orderBy = $this->processOrderBy($input); - $columnsMap = $this->resolveColumnsMap($input); - $data = [ - ShortUrlsParamsInputFilter::SEARCH_TERM => $searchTerm, - ShortUrlsParamsInputFilter::DOMAIN => $domain, - ShortUrlsParamsInputFilter::TAGS => $tags, + ShortUrlsParamsInputFilter::SEARCH_TERM => $input->getOption('search-term'), + ShortUrlsParamsInputFilter::DOMAIN => $input->getOption('domain'), + ShortUrlsParamsInputFilter::TAGS => $this->tagsOption->get($input), ShortUrlsParamsInputFilter::TAGS_MODE => $tagsMode, - ShortUrlsParamsInputFilter::EXCLUDE_TAGS => $excludeTags, + ShortUrlsParamsInputFilter::EXCLUDE_TAGS => $input->getOption('exclude-tag'), ShortUrlsParamsInputFilter::EXCLUDE_TAGS_MODE => $excludeTagsMode, - ShortUrlsParamsInputFilter::ORDER_BY => $orderBy, - ShortUrlsParamsInputFilter::START_DATE => $startDate?->toAtomString(), - ShortUrlsParamsInputFilter::END_DATE => $endDate?->toAtomString(), + ShortUrlsParamsInputFilter::ORDER_BY => $this->processOrderBy($input), + ShortUrlsParamsInputFilter::START_DATE => $this->startDateOption->get($input, $output)?->toAtomString(), + ShortUrlsParamsInputFilter::END_DATE => $this->endDateOption->get($input, $output)?->toAtomString(), ShortUrlsParamsInputFilter::EXCLUDE_MAX_VISITS_REACHED => $input->getOption('exclude-max-visits-reached'), ShortUrlsParamsInputFilter::EXCLUDE_PAST_VALID_UNTIL => $input->getOption('exclude-past-valid-until'), + ShortUrlsParamsInputFilter::API_KEY_NAME => $input->getOption('api-key-name'), ]; + $all = $input->getOption('all'); if ($all) { $data[ShortUrlsParamsInputFilter::ITEMS_PER_PAGE] = Paginator::ALL_ITEMS; } + $columnsMap = $this->resolveColumnsMap($input); do { $data[ShortUrlsParamsInputFilter::PAGE] = $page; $result = $this->renderPage($output, $columnsMap, ShortUrlsParams::fromRawData($data), $all); @@ -184,7 +181,7 @@ class ListShortUrlsCommand extends Command $continue = $result->hasNextPage() && $io->confirm( sprintf('Continue with page %s?', $page), - false, + default: false, ); } while ($continue); From 9c22c7fc9c16b042f4bda98bdd2bde2d9d39556a Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 22 Oct 2025 08:28:45 +0200 Subject: [PATCH 3/4] Add more tests for apiKeyName short URLs filtering --- docs/swagger/paths/v1_short-urls.json | 9 +++++++++ .../ShortUrl/ListShortUrlsCommandTest.php | 14 ++++++++++++++ .../Rest/test-api/Action/ListShortUrlsTest.php | 18 ++++++++++++++++++ 3 files changed, 41 insertions(+) diff --git a/docs/swagger/paths/v1_short-urls.json b/docs/swagger/paths/v1_short-urls.json index a29c221e..1a58ef14 100644 --- a/docs/swagger/paths/v1_short-urls.json +++ b/docs/swagger/paths/v1_short-urls.json @@ -156,6 +156,15 @@ "schema": { "type": "string" } + }, + { + "name": "apiKeyName", + "in": "query", + "description": "Only get short URLs created with this API key.
This value is **ignored** if the request is performed with a non-admin API key that does not match this name.", + "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 755d179c..8d75322e 100644 --- a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php @@ -209,6 +209,7 @@ class ListShortUrlsCommandTest extends TestCase string|null $endDate = null, array $excludeTags = [], string $excludeTagsMode = TagsMode::ANY->value, + string|null $apiKeyName = null, ): void { $this->shortUrlService->expects($this->once())->method('listShortUrls')->with(ShortUrlsParams::fromRawData([ 'page' => $page, @@ -219,6 +220,7 @@ class ListShortUrlsCommandTest extends TestCase 'endDate' => $endDate !== null ? Chronos::parse($endDate)->toAtomString() : null, 'excludeTags' => $excludeTags, 'excludeTagsMode' => $excludeTagsMode, + 'apiKeyName' => $apiKeyName, ]))->willReturn(new Paginator(new ArrayAdapter([]))); $this->commandTester->setInputs(['n']); @@ -275,6 +277,18 @@ class ListShortUrlsCommandTest extends TestCase ['foo', 'bar'], TagsMode::ALL->value, ]; + yield [ + ['--api-key-name' => 'foo'], + 1, + null, + [], + TagsMode::ANY->value, + null, + null, + [], + TagsMode::ANY->value, + 'foo', + ]; } #[Test, DataProvider('provideOrderBy')] diff --git a/module/Rest/test-api/Action/ListShortUrlsTest.php b/module/Rest/test-api/Action/ListShortUrlsTest.php index 34aaa802..14b52d42 100644 --- a/module/Rest/test-api/Action/ListShortUrlsTest.php +++ b/module/Rest/test-api/Action/ListShortUrlsTest.php @@ -297,6 +297,24 @@ class ListShortUrlsTest extends ApiTestCase self::SHORT_URL_DOCS, ]]; + // Filter by API key name + yield [['apiKeyName' => 'author_api_key'], [ + self::SHORT_URL_CUSTOM_SLUG, + self::SHORT_URL_META, + self::SHORT_URL_SHLINK_WITH_TITLE, + ]]; + yield [['apiKeyName' => 'invalid'], []]; + yield [['apiKeyName' => 'valid_api_key'], [ + // If the author_api_key is used, the `apiKeyName` param is ignored + self::SHORT_URL_CUSTOM_SLUG, + self::SHORT_URL_META, + self::SHORT_URL_SHLINK_WITH_TITLE, + ], 'author_api_key']; + yield [['apiKeyName' => 'valid_api_key'], [ + // If the domain_api_key is used, the `apiKeyName` param is ignored + self::SHORT_URL_CUSTOM_DOMAIN, + ], 'domain_api_key']; + // Different API keys yield [[], [ self::SHORT_URL_CUSTOM_SLUG, From 02500143c19130355e29d2ac5e60f50a0b3d7481 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 22 Oct 2025 08:31:04 +0200 Subject: [PATCH 4/4] Update changelog --- CHANGELOG.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 151a3109..23dbbdd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ## [Unreleased] ### Added -* [#2327](https://github.com/shlinkio/shlink/issues/2327) Allow filtering short URLs lists by those not including certain tags. +* [#2327](https://github.com/shlinkio/shlink/issues/2327) Allow filtering short URL lists by those not including certain tags. Now, the `GET /short-urls` endpoint accepts two new params: `excludeTags`, which is an array of strings with the tags that should not be included, and `excludeTagsMode`, which accepts the values `any` and `all`, and determines if short URLs should be filtered out if they contain any of the excluded tags, or all the excluded tags. Additionally, the `short-url:list` command also supports the same feature via `--exclude-tag` option, which requires a value and can be provided multiple times, and `--exclude-tags-all`, which does not expect a value and determines if the mode should be `all`, or `any`. +* [#2192](https://github.com/shlinkio/shlink/issues/2192) Allow filtering short URL lists by the API key that was used to create them. + + Now, the `GET /short-urls` endpoint accepts a new `apiKeyName` param, which is ignored if the request is performed with a non-admin API key which name does not match the one provided here. + + Additionally, the `short-url:list` command also supports the same feature via the `--api-key-name` option. + ### Changed * *Nothing*