mirror of
https://github.com/shlinkio/shlink.git
synced 2025-12-10 00:10:57 -06:00
Allow filtering short URLs by API key name
This commit is contained in:
parent
30ed1d7c6b
commit
506ed6207f
@ -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
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user