mirror of
https://github.com/shlinkio/shlink.git
synced 2026-02-28 04:03:12 +08:00
Merge pull request #2499 from acelaya-forks/api-key-filter
Allow filtering short URLs by API key name
This commit is contained in:
@@ -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*
|
||||
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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')]
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user