Allow filtering short URLs by API key name

This commit is contained in:
Alejandro Celaya 2025-10-21 12:25:06 +02:00
parent 30ed1d7c6b
commit 506ed6207f
6 changed files with 107 additions and 15 deletions

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