Merge pull request #2499 from acelaya-forks/api-key-filter

Allow filtering short URLs by API key name
This commit is contained in:
Alejandro Celaya
2025-10-22 08:40:33 +02:00
committed by GitHub
11 changed files with 172 additions and 36 deletions

View File

@@ -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*

View File

@@ -156,6 +156,15 @@
"schema": {
"type": "string"
}
},
{
"name": "apiKeyName",
"in": "query",
"description": "Only get short URLs created with this API key.<br />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": [

View File

@@ -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 <options=bold>%s</>?', $page),
false,
default: false,
);
} while ($continue);

View File

@@ -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')]

View File

@@ -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),
);
}

View File

@@ -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

View File

@@ -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,
);
}
}

View File

@@ -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,
);
}
}

View File

@@ -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;

View File

@@ -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
)));
}
}

View File

@@ -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,