diff --git a/module/CLI/src/ApiKey/RoleResolver.php b/module/CLI/src/ApiKey/RoleResolver.php index c1ae8f05..5a60120f 100644 --- a/module/CLI/src/ApiKey/RoleResolver.php +++ b/module/CLI/src/ApiKey/RoleResolver.php @@ -22,6 +22,7 @@ class RoleResolver implements RoleResolverInterface { $domainAuthority = $input->getOption(Role::DOMAIN_SPECIFIC->paramName()); $author = $input->getOption(Role::AUTHORED_SHORT_URLS->paramName()); + $noOrphanVisits = $input->getOption(Role::NO_ORPHAN_VISITS->paramName()); $roleDefinitions = []; if ($author) { @@ -30,6 +31,9 @@ class RoleResolver implements RoleResolverInterface if (is_string($domainAuthority)) { $roleDefinitions[] = $this->resolveRoleForAuthority($domainAuthority); } + if ($noOrphanVisits) { + $roleDefinitions[] = RoleDefinition::forOrphanVisitsExcluded(); + } return $roleDefinitions; } diff --git a/module/CLI/src/Command/Api/GenerateKeyCommand.php b/module/CLI/src/Command/Api/GenerateKeyCommand.php index c2d6cf10..ab4b5d54 100644 --- a/module/CLI/src/Command/Api/GenerateKeyCommand.php +++ b/module/CLI/src/Command/Api/GenerateKeyCommand.php @@ -25,8 +25,8 @@ class GenerateKeyCommand extends Command public const NAME = 'api-key:generate'; public function __construct( - private ApiKeyServiceInterface $apiKeyService, - private RoleResolverInterface $roleResolver, + private readonly ApiKeyServiceInterface $apiKeyService, + private readonly RoleResolverInterface $roleResolver, ) { parent::__construct(); } @@ -35,6 +35,8 @@ class GenerateKeyCommand extends Command { $authorOnly = Role::AUTHORED_SHORT_URLS->paramName(); $domainOnly = Role::DOMAIN_SPECIFIC->paramName(); + $noOrphanVisits = Role::NO_ORPHAN_VISITS->paramName(); + $help = <<%command.name% generates a new valid API key. @@ -52,7 +54,8 @@ class GenerateKeyCommand extends Command * Can interact with short URLs created with this API key: %command.full_name% --{$authorOnly} * Can interact with short URLs for one domain: %command.full_name% --{$domainOnly}=example.com - * Both: %command.full_name% --{$authorOnly} --{$domainOnly}=example.com + * Cannot see orphan visits: %command.full_name% --{$noOrphanVisits} + * All: %command.full_name% --{$authorOnly} --{$domainOnly}=example.com --{$noOrphanVisits} HELP; $this @@ -85,6 +88,12 @@ class GenerateKeyCommand extends Command Role::DOMAIN_SPECIFIC->value, ), ) + ->addOption( + $noOrphanVisits, + 'o', + InputOption::VALUE_NONE, + sprintf('Adds the "%s" role to the new API key.', Role::NO_ORPHAN_VISITS->value), + ) ->setHelp($help); } diff --git a/module/CLI/src/Command/Api/ListKeysCommand.php b/module/CLI/src/Command/Api/ListKeysCommand.php index 87b239b7..4fd4b005 100644 --- a/module/CLI/src/Command/Api/ListKeysCommand.php +++ b/module/CLI/src/Command/Api/ListKeysCommand.php @@ -27,7 +27,7 @@ class ListKeysCommand extends Command public const NAME = 'api-key:list'; - public function __construct(private ApiKeyServiceInterface $apiKeyService) + public function __construct(private readonly ApiKeyServiceInterface $apiKeyService) { parent::__construct(); } @@ -60,10 +60,7 @@ class ListKeysCommand extends Command } $rowData[] = $expiration?->toAtomString() ?? '-'; $rowData[] = ApiKey::isAdmin($apiKey) ? 'Admin' : implode("\n", $apiKey->mapRoles( - fn (Role $role, array $meta) => - empty($meta) - ? $role->toFriendlyName() - : sprintf('%s: %s', $role->toFriendlyName(), Role::domainAuthorityFromMeta($meta)), + fn (Role $role, array $meta) => $role->toFriendlyName($meta), )); return $rowData; diff --git a/module/Core/src/Domain/Repository/DomainRepository.php b/module/Core/src/Domain/Repository/DomainRepository.php index dcbc3d9e..aae44aed 100644 --- a/module/Core/src/Domain/Repository/DomainRepository.php +++ b/module/Core/src/Domain/Repository/DomainRepository.php @@ -79,6 +79,7 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe yield from $apiKey?->mapRoles(fn (Role $role, array $meta) => match ($role) { Role::DOMAIN_SPECIFIC => ['d', new IsDomain(Role::domainIdFromMeta($meta))], Role::AUTHORED_SHORT_URLS => ['s', new BelongsToApiKey($apiKey)], + default => null, }) ?? []; } } diff --git a/module/Core/src/Tag/Repository/TagRepository.php b/module/Core/src/Tag/Repository/TagRepository.php index 68e5df4b..adfb6480 100644 --- a/module/Core/src/Tag/Repository/TagRepository.php +++ b/module/Core/src/Tag/Repository/TagRepository.php @@ -56,6 +56,7 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito Role::AUTHORED_SHORT_URLS => $qb->andWhere( $qb->expr()->eq('s.author_api_key_id', $conn->quote($apiKey->getId())), ), + default => $qb, }); // For admins and when no API key is present, we'll return tags which are not linked to any short URL diff --git a/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKey.php b/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKey.php index 1e0b041b..7d0f3583 100644 --- a/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKey.php +++ b/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKey.php @@ -44,7 +44,7 @@ return static function (ClassMetadata $metadata, array $emConfig): void { $builder->createOneToMany('roles', ApiKeyRole::class) ->mappedBy('apiKey') - ->setIndexBy('roleName') + ->setIndexBy('role') ->cascadePersist() ->orphanRemoval() ->build(); diff --git a/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKeyRole.php b/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKeyRole.php index 8df324a4..04d1cf9d 100644 --- a/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKeyRole.php +++ b/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKeyRole.php @@ -25,7 +25,7 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->build(); (new FieldBuilder($builder, [ - 'fieldName' => 'roleName', + 'fieldName' => 'role', 'type' => Types::STRING, 'enumType' => Role::class, ]))->columnName('role_name') diff --git a/module/Rest/src/ApiKey/Model/RoleDefinition.php b/module/Rest/src/ApiKey/Model/RoleDefinition.php index 403e6214..819a224c 100644 --- a/module/Rest/src/ApiKey/Model/RoleDefinition.php +++ b/module/Rest/src/ApiKey/Model/RoleDefinition.php @@ -25,4 +25,9 @@ final class RoleDefinition ['domain_id' => $domain->getId(), 'authority' => $domain->authority], ); } + + public static function forOrphanVisitsExcluded(): self + { + return new self(Role::NO_ORPHAN_VISITS, []); + } } diff --git a/module/Rest/src/ApiKey/Role.php b/module/Rest/src/ApiKey/Role.php index 5a4edb81..dd2d8ae7 100644 --- a/module/Rest/src/ApiKey/Role.php +++ b/module/Rest/src/ApiKey/Role.php @@ -12,16 +12,20 @@ use Shlinkio\Shlink\Core\ShortUrl\Spec\BelongsToDomain; use Shlinkio\Shlink\Core\ShortUrl\Spec\BelongsToDomainInlined; use Shlinkio\Shlink\Rest\Entity\ApiKeyRole; +use function sprintf; + enum Role: string { case AUTHORED_SHORT_URLS = 'AUTHORED_SHORT_URLS'; case DOMAIN_SPECIFIC = 'DOMAIN_SPECIFIC'; + case NO_ORPHAN_VISITS = 'NO_ORPHAN_VISITS'; - public function toFriendlyName(): string + public function toFriendlyName(array $meta): string { return match ($this) { self::AUTHORED_SHORT_URLS => 'Author only', - self::DOMAIN_SPECIFIC => 'Domain only', + self::DOMAIN_SPECIFIC => sprintf('Domain only: %s', Role::domainAuthorityFromMeta($meta)), + self::NO_ORPHAN_VISITS => 'No orphan visits', }; } @@ -30,6 +34,7 @@ enum Role: string return match ($this) { self::AUTHORED_SHORT_URLS => 'author-only', self::DOMAIN_SPECIFIC => 'domain-only', + self::NO_ORPHAN_VISITS => 'no-orphan-visits', }; } @@ -38,6 +43,7 @@ enum Role: string return match ($role->role()) { self::AUTHORED_SHORT_URLS => new BelongsToApiKey($role->apiKey(), $context), self::DOMAIN_SPECIFIC => new BelongsToDomain(self::domainIdFromMeta($role->meta()), $context), + default => Spec::andX(), }; } @@ -46,6 +52,7 @@ enum Role: string return match ($role->role()) { self::AUTHORED_SHORT_URLS => Spec::andX(new BelongsToApiKeyInlined($role->apiKey())), self::DOMAIN_SPECIFIC => Spec::andX(new BelongsToDomainInlined(self::domainIdFromMeta($role->meta()))), + default => Spec::andX(), }; } diff --git a/module/Rest/src/Entity/ApiKeyRole.php b/module/Rest/src/Entity/ApiKeyRole.php index 8491cfce..6fadb839 100644 --- a/module/Rest/src/Entity/ApiKeyRole.php +++ b/module/Rest/src/Entity/ApiKeyRole.php @@ -9,13 +9,24 @@ use Shlinkio\Shlink\Rest\ApiKey\Role; class ApiKeyRole extends AbstractEntity { - public function __construct(private Role $roleName, private array $meta, private ApiKey $apiKey) + public function __construct(public readonly Role $role, private array $meta, public readonly ApiKey $apiKey) { } + /** + * @deprecated Use property access directly + */ public function role(): Role { - return $this->roleName; + return $this->role; + } + + /** + * @deprecated Use property access directly + */ + public function apiKey(): ApiKey + { + return $this->apiKey; } public function meta(): array @@ -27,9 +38,4 @@ class ApiKeyRole extends AbstractEntity { $this->meta = $newMeta; } - - public function apiKey(): ApiKey - { - return $this->apiKey; - } } diff --git a/module/Rest/test/ApiKey/RoleTest.php b/module/Rest/test/ApiKey/RoleTest.php index b572630b..bf02318a 100644 --- a/module/Rest/test/ApiKey/RoleTest.php +++ b/module/Rest/test/ApiKey/RoleTest.php @@ -86,14 +86,15 @@ class RoleTest extends TestCase } #[Test, DataProvider('provideRoleNames')] - public function getsExpectedRoleFriendlyName(Role $role, string $expectedFriendlyName): void + public function getsExpectedRoleFriendlyName(Role $role, array $meta, string $expectedFriendlyName): void { - self::assertEquals($expectedFriendlyName, $role->toFriendlyName()); + self::assertEquals($expectedFriendlyName, $role->toFriendlyName($meta)); } public static function provideRoleNames(): iterable { - yield Role::AUTHORED_SHORT_URLS->value => [Role::AUTHORED_SHORT_URLS, 'Author only']; - yield Role::DOMAIN_SPECIFIC->value => [Role::DOMAIN_SPECIFIC, 'Domain only']; + yield Role::AUTHORED_SHORT_URLS->value => [Role::AUTHORED_SHORT_URLS, [], 'Author only']; + yield Role::DOMAIN_SPECIFIC->value => [Role::DOMAIN_SPECIFIC, ['authority' => 's.test'], 'Domain only: s.test']; + yield Role::NO_ORPHAN_VISITS->value => [Role::NO_ORPHAN_VISITS, [], 'No orphan visits']; } }