mirror of
https://github.com/shlinkio/shlink.git
synced 2024-11-22 08:56:42 -06:00
New CLI command to create the initial API key idempotently
This commit is contained in:
parent
6db46b50e9
commit
637d8334f4
@ -10,12 +10,17 @@ if [ -z "${GEOLITE_LICENSE_KEY}" ] || [ "${SKIP_INITIAL_GEOLITE_DOWNLOAD}" == "t
|
|||||||
flags="${flags} --skip-download-geolite"
|
flags="${flags} --skip-download-geolite"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# TODO If INITIAL_API_KEY was provided, create an initial API key
|
||||||
|
#if [ -n "${INITIAL_API_KEY}" ]; then
|
||||||
|
# flags="${flags} --initial-api-key=${INITIAL_API_KEY}"
|
||||||
|
#fi
|
||||||
|
|
||||||
php vendor/bin/shlink-installer init ${flags}
|
php vendor/bin/shlink-installer init ${flags}
|
||||||
|
|
||||||
# TODO If INIT_API_KEY was provided, create an initial API key
|
# If INITIAL_API_KEY was provided, create an initial API key
|
||||||
#if [ -n "${INIT_API_KEY}" ]; then
|
if [ -n "${INITIAL_API_KEY}" ]; then
|
||||||
# php bin/cli api-key:initial "${INIT_API_KEY}"
|
php bin/cli api-key:initial "${INITIAL_API_KEY}"
|
||||||
#fi
|
fi
|
||||||
|
|
||||||
# Periodically run visit:locate every hour, if ENABLE_PERIODIC_VISIT_LOCATE=true was provided and running as root
|
# Periodically run visit:locate every hour, if ENABLE_PERIODIC_VISIT_LOCATE=true was provided and running as root
|
||||||
# FIXME: ENABLE_PERIODIC_VISIT_LOCATE is deprecated. Remove cron support in Shlink 4.0.0
|
# FIXME: ENABLE_PERIODIC_VISIT_LOCATE is deprecated. Remove cron support in Shlink 4.0.0
|
||||||
|
@ -24,6 +24,7 @@ return [
|
|||||||
Command\Api\GenerateKeyCommand::NAME => Command\Api\GenerateKeyCommand::class,
|
Command\Api\GenerateKeyCommand::NAME => Command\Api\GenerateKeyCommand::class,
|
||||||
Command\Api\DisableKeyCommand::NAME => Command\Api\DisableKeyCommand::class,
|
Command\Api\DisableKeyCommand::NAME => Command\Api\DisableKeyCommand::class,
|
||||||
Command\Api\ListKeysCommand::NAME => Command\Api\ListKeysCommand::class,
|
Command\Api\ListKeysCommand::NAME => Command\Api\ListKeysCommand::class,
|
||||||
|
Command\Api\InitialApiKeyCommand::NAME => Command\Api\InitialApiKeyCommand::class,
|
||||||
|
|
||||||
Command\Tag\ListTagsCommand::NAME => Command\Tag\ListTagsCommand::class,
|
Command\Tag\ListTagsCommand::NAME => Command\Tag\ListTagsCommand::class,
|
||||||
Command\Tag\RenameTagCommand::NAME => Command\Tag\RenameTagCommand::class,
|
Command\Tag\RenameTagCommand::NAME => Command\Tag\RenameTagCommand::class,
|
||||||
|
@ -53,6 +53,7 @@ return [
|
|||||||
Command\Api\GenerateKeyCommand::class => ConfigAbstractFactory::class,
|
Command\Api\GenerateKeyCommand::class => ConfigAbstractFactory::class,
|
||||||
Command\Api\DisableKeyCommand::class => ConfigAbstractFactory::class,
|
Command\Api\DisableKeyCommand::class => ConfigAbstractFactory::class,
|
||||||
Command\Api\ListKeysCommand::class => ConfigAbstractFactory::class,
|
Command\Api\ListKeysCommand::class => ConfigAbstractFactory::class,
|
||||||
|
Command\Api\InitialApiKeyCommand::class => ConfigAbstractFactory::class,
|
||||||
|
|
||||||
Command\Tag\ListTagsCommand::class => ConfigAbstractFactory::class,
|
Command\Tag\ListTagsCommand::class => ConfigAbstractFactory::class,
|
||||||
Command\Tag\RenameTagCommand::class => ConfigAbstractFactory::class,
|
Command\Tag\RenameTagCommand::class => ConfigAbstractFactory::class,
|
||||||
@ -105,6 +106,7 @@ return [
|
|||||||
Command\Api\GenerateKeyCommand::class => [ApiKeyService::class, ApiKey\RoleResolver::class],
|
Command\Api\GenerateKeyCommand::class => [ApiKeyService::class, ApiKey\RoleResolver::class],
|
||||||
Command\Api\DisableKeyCommand::class => [ApiKeyService::class],
|
Command\Api\DisableKeyCommand::class => [ApiKeyService::class],
|
||||||
Command\Api\ListKeysCommand::class => [ApiKeyService::class],
|
Command\Api\ListKeysCommand::class => [ApiKeyService::class],
|
||||||
|
Command\Api\InitialApiKeyCommand::class => [ApiKeyService::class],
|
||||||
|
|
||||||
Command\Tag\ListTagsCommand::class => [TagService::class],
|
Command\Tag\ListTagsCommand::class => [TagService::class],
|
||||||
Command\Tag\RenameTagCommand::class => [TagService::class],
|
Command\Tag\RenameTagCommand::class => [TagService::class],
|
||||||
|
43
module/CLI/src/Command/Api/InitialApiKeyCommand.php
Normal file
43
module/CLI/src/Command/Api/InitialApiKeyCommand.php
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shlinkio\Shlink\CLI\Command\Api;
|
||||||
|
|
||||||
|
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||||
|
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
|
class InitialApiKeyCommand extends Command
|
||||||
|
{
|
||||||
|
public const NAME = 'api-key:initial';
|
||||||
|
|
||||||
|
public function __construct(private readonly ApiKeyServiceInterface $apiKeyService)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this
|
||||||
|
->setHidden()
|
||||||
|
->setName(self::NAME)
|
||||||
|
->setDescription('Tries to create initial API key')
|
||||||
|
->addArgument('apiKey', InputArgument::REQUIRED, 'The initial API to create');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): ?int
|
||||||
|
{
|
||||||
|
$key = $input->getArgument('apiKey');
|
||||||
|
$result = $this->apiKeyService->createInitial($key);
|
||||||
|
|
||||||
|
if ($result === null && $output->isVerbose()) {
|
||||||
|
$output->writeln('<comment>Other API keys already exist. Initial API key creation skipped.</comment>');
|
||||||
|
}
|
||||||
|
|
||||||
|
return ExitCode::EXIT_SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
@ -47,7 +47,6 @@ enum EnvVars: string
|
|||||||
case PORT = 'PORT';
|
case PORT = 'PORT';
|
||||||
case TASK_WORKER_NUM = 'TASK_WORKER_NUM';
|
case TASK_WORKER_NUM = 'TASK_WORKER_NUM';
|
||||||
case WEB_WORKER_NUM = 'WEB_WORKER_NUM';
|
case WEB_WORKER_NUM = 'WEB_WORKER_NUM';
|
||||||
case INITIAL_API_KEY = 'INITIAL_API_KEY';
|
|
||||||
case ANONYMIZE_REMOTE_ADDR = 'ANONYMIZE_REMOTE_ADDR';
|
case ANONYMIZE_REMOTE_ADDR = 'ANONYMIZE_REMOTE_ADDR';
|
||||||
case TRACK_ORPHAN_VISITS = 'TRACK_ORPHAN_VISITS';
|
case TRACK_ORPHAN_VISITS = 'TRACK_ORPHAN_VISITS';
|
||||||
case DISABLE_TRACK_PARAM = 'DISABLE_TRACK_PARAM';
|
case DISABLE_TRACK_PARAM = 'DISABLE_TRACK_PARAM';
|
||||||
|
@ -1,26 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Shlinkio\Shlink\Rest;
|
|
||||||
|
|
||||||
use Mezzio\Application;
|
|
||||||
use Shlinkio\Shlink\Core\Config\EnvVars;
|
|
||||||
|
|
||||||
use const PHP_SAPI;
|
|
||||||
|
|
||||||
return [
|
|
||||||
|
|
||||||
// We will try to load the initial API key only for openswoole and RoadRunner.
|
|
||||||
// For php-fpm, the check against the database would happen on every request, resulting in a very bad performance.
|
|
||||||
'initial_api_key' => PHP_SAPI !== 'cli' ? null : EnvVars::INITIAL_API_KEY->loadFromEnv(),
|
|
||||||
|
|
||||||
'dependencies' => [
|
|
||||||
'delegators' => [
|
|
||||||
Application::class => [
|
|
||||||
ApiKey\InitialApiKeyDelegator::class,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
];
|
|
@ -1,31 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Shlinkio\Shlink\Rest\ApiKey;
|
|
||||||
|
|
||||||
use Doctrine\ORM\EntityManager;
|
|
||||||
use Mezzio\Application;
|
|
||||||
use Psr\Container\ContainerInterface;
|
|
||||||
use Shlinkio\Shlink\Rest\ApiKey\Repository\ApiKeyRepositoryInterface;
|
|
||||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
|
||||||
|
|
||||||
class InitialApiKeyDelegator
|
|
||||||
{
|
|
||||||
public function __invoke(ContainerInterface $container, string $serviceName, callable $callback): Application
|
|
||||||
{
|
|
||||||
$initialApiKey = $container->get('config')['initial_api_key'] ?? null;
|
|
||||||
if (! empty($initialApiKey)) {
|
|
||||||
$this->createInitialApiKey($initialApiKey, $container);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $callback();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function createInitialApiKey(string $initialApiKey, ContainerInterface $container): void
|
|
||||||
{
|
|
||||||
/** @var ApiKeyRepositoryInterface $repo */
|
|
||||||
$repo = $container->get(EntityManager::class)->getRepository(ApiKey::class);
|
|
||||||
$repo->createInitialApiKey($initialApiKey);
|
|
||||||
}
|
|
||||||
}
|
|
@ -11,10 +11,13 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
|||||||
|
|
||||||
class ApiKeyRepository extends EntitySpecificationRepository implements ApiKeyRepositoryInterface
|
class ApiKeyRepository extends EntitySpecificationRepository implements ApiKeyRepositoryInterface
|
||||||
{
|
{
|
||||||
public function createInitialApiKey(string $apiKey): void
|
/**
|
||||||
|
* Will create provided API key with admin permissions, only if there's no other API keys yet
|
||||||
|
*/
|
||||||
|
public function createInitialApiKey(string $apiKey): ?ApiKey
|
||||||
{
|
{
|
||||||
$em = $this->getEntityManager();
|
$em = $this->getEntityManager();
|
||||||
$em->wrapInTransaction(function () use ($apiKey, $em): void {
|
return $em->wrapInTransaction(function () use ($apiKey, $em): ?ApiKey {
|
||||||
// Ideally this would be a SELECT COUNT(...), but MsSQL and Postgres do not allow locking on aggregates
|
// Ideally this would be a SELECT COUNT(...), but MsSQL and Postgres do not allow locking on aggregates
|
||||||
// Because of that we check if at least one result exists
|
// Because of that we check if at least one result exists
|
||||||
$firstResult = $em->createQueryBuilder()->select('a.id')
|
$firstResult = $em->createQueryBuilder()->select('a.id')
|
||||||
@ -24,10 +27,16 @@ class ApiKeyRepository extends EntitySpecificationRepository implements ApiKeyRe
|
|||||||
->setLockMode(LockMode::PESSIMISTIC_WRITE)
|
->setLockMode(LockMode::PESSIMISTIC_WRITE)
|
||||||
->getOneOrNullResult();
|
->getOneOrNullResult();
|
||||||
|
|
||||||
if ($firstResult === null) {
|
// Do not create an initial API key if other keys already exist
|
||||||
$em->persist(ApiKey::fromMeta(ApiKeyMeta::fromParams(key: $apiKey)));
|
if ($firstResult !== null) {
|
||||||
$em->flush();
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$new = ApiKey::fromMeta(ApiKeyMeta::fromParams(key: $apiKey));
|
||||||
|
$em->persist($new);
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
return $new;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -6,11 +6,12 @@ namespace Shlinkio\Shlink\Rest\ApiKey\Repository;
|
|||||||
|
|
||||||
use Doctrine\Persistence\ObjectRepository;
|
use Doctrine\Persistence\ObjectRepository;
|
||||||
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepositoryInterface;
|
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepositoryInterface;
|
||||||
|
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||||
|
|
||||||
interface ApiKeyRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface
|
interface ApiKeyRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Will create provided API key only if there's no API keys yet
|
* Will create provided API key only if there's no API keys yet
|
||||||
*/
|
*/
|
||||||
public function createInitialApiKey(string $apiKey): void;
|
public function createInitialApiKey(string $apiKey): ?ApiKey;
|
||||||
}
|
}
|
||||||
|
@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Rest\Service;
|
|||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
|
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
|
||||||
use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
|
use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
|
||||||
|
use Shlinkio\Shlink\Rest\ApiKey\Repository\ApiKeyRepositoryInterface;
|
||||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
@ -27,6 +28,13 @@ class ApiKeyService implements ApiKeyServiceInterface
|
|||||||
return $apiKey;
|
return $apiKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function createInitial(string $key): ?ApiKey
|
||||||
|
{
|
||||||
|
/** @var ApiKeyRepositoryInterface $repo */
|
||||||
|
$repo = $this->em->getRepository(ApiKey::class);
|
||||||
|
return $repo->createInitialApiKey($key);
|
||||||
|
}
|
||||||
|
|
||||||
public function check(string $key): ApiKeyCheckResult
|
public function check(string $key): ApiKeyCheckResult
|
||||||
{
|
{
|
||||||
$apiKey = $this->getByKey($key);
|
$apiKey = $this->getByKey($key);
|
||||||
|
@ -12,6 +12,8 @@ interface ApiKeyServiceInterface
|
|||||||
{
|
{
|
||||||
public function create(ApiKeyMeta $apiKeyMeta): ApiKey;
|
public function create(ApiKeyMeta $apiKeyMeta): ApiKey;
|
||||||
|
|
||||||
|
public function createInitial(string $key): ?ApiKey;
|
||||||
|
|
||||||
public function check(string $key): ApiKeyCheckResult;
|
public function check(string $key): ApiKeyCheckResult;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -22,10 +22,10 @@ class ApiKeyRepositoryTest extends DatabaseTestCase
|
|||||||
public function initialApiKeyIsCreatedOnlyOfNoApiKeysExistYet(): void
|
public function initialApiKeyIsCreatedOnlyOfNoApiKeysExistYet(): void
|
||||||
{
|
{
|
||||||
self::assertCount(0, $this->repo->findAll());
|
self::assertCount(0, $this->repo->findAll());
|
||||||
$this->repo->createInitialApiKey('initial_value');
|
self::assertNotNull($this->repo->createInitialApiKey('initial_value'));
|
||||||
self::assertCount(1, $this->repo->findAll());
|
self::assertCount(1, $this->repo->findAll());
|
||||||
self::assertCount(1, $this->repo->findBy(['key' => 'initial_value']));
|
self::assertCount(1, $this->repo->findBy(['key' => 'initial_value']));
|
||||||
$this->repo->createInitialApiKey('another_one');
|
self::assertNull($this->repo->createInitialApiKey('another_one'));
|
||||||
self::assertCount(1, $this->repo->findAll());
|
self::assertCount(1, $this->repo->findAll());
|
||||||
self::assertCount(0, $this->repo->findBy(['key' => 'another_one']));
|
self::assertCount(0, $this->repo->findBy(['key' => 'another_one']));
|
||||||
}
|
}
|
||||||
|
@ -1,57 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace ShlinkioTest\Shlink\Rest\ApiKey;
|
|
||||||
|
|
||||||
use Doctrine\ORM\EntityManager;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
|
||||||
use Mezzio\Application;
|
|
||||||
use PHPUnit\Framework\Attributes\DataProvider;
|
|
||||||
use PHPUnit\Framework\Attributes\Test;
|
|
||||||
use PHPUnit\Framework\MockObject\MockObject;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
use Psr\Container\ContainerInterface;
|
|
||||||
use Shlinkio\Shlink\Rest\ApiKey\InitialApiKeyDelegator;
|
|
||||||
use Shlinkio\Shlink\Rest\ApiKey\Repository\ApiKeyRepositoryInterface;
|
|
||||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
|
||||||
|
|
||||||
class InitialApiKeyDelegatorTest extends TestCase
|
|
||||||
{
|
|
||||||
private InitialApiKeyDelegator $delegator;
|
|
||||||
private MockObject & ContainerInterface $container;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
|
||||||
{
|
|
||||||
$this->delegator = new InitialApiKeyDelegator();
|
|
||||||
$this->container = $this->createMock(ContainerInterface::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test, DataProvider('provideConfigs')]
|
|
||||||
public function apiKeyIsInitializedWhenAppropriate(array $config, int $expectedCalls): void
|
|
||||||
{
|
|
||||||
$app = $this->createMock(Application::class);
|
|
||||||
$apiKeyRepo = $this->createMock(ApiKeyRepositoryInterface::class);
|
|
||||||
$apiKeyRepo->expects($this->exactly($expectedCalls))->method('createInitialApiKey');
|
|
||||||
$em = $this->createMock(EntityManagerInterface::class);
|
|
||||||
$em->expects($this->exactly($expectedCalls))->method('getRepository')->with(ApiKey::class)->willReturn(
|
|
||||||
$apiKeyRepo,
|
|
||||||
);
|
|
||||||
$this->container->expects($this->exactly($expectedCalls + 1))->method('get')->willReturnMap([
|
|
||||||
['config', $config],
|
|
||||||
[EntityManager::class, $em],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$result = ($this->delegator)($this->container, '', fn () => $app);
|
|
||||||
|
|
||||||
self::assertSame($result, $app);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function provideConfigs(): iterable
|
|
||||||
{
|
|
||||||
yield 'no api key' => [[], 0];
|
|
||||||
yield 'null api key' => [['initial_api_key' => null], 0];
|
|
||||||
yield 'empty api key' => [['initial_api_key' => ''], 0];
|
|
||||||
yield 'valid api key' => [['initial_api_key' => 'the_initial_key'], 1];
|
|
||||||
}
|
|
||||||
}
|
|
@ -24,11 +24,10 @@ class ConfigProviderTest extends TestCase
|
|||||||
{
|
{
|
||||||
$config = ($this->configProvider)();
|
$config = ($this->configProvider)();
|
||||||
|
|
||||||
self::assertCount(5, $config);
|
self::assertCount(4, $config);
|
||||||
self::assertArrayHasKey('dependencies', $config);
|
self::assertArrayHasKey('dependencies', $config);
|
||||||
self::assertArrayHasKey('auth', $config);
|
self::assertArrayHasKey('auth', $config);
|
||||||
self::assertArrayHasKey('entity_manager', $config);
|
self::assertArrayHasKey('entity_manager', $config);
|
||||||
self::assertArrayHasKey('initial_api_key', $config);
|
|
||||||
self::assertArrayHasKey(ConfigAbstractFactory::class, $config);
|
self::assertArrayHasKey(ConfigAbstractFactory::class, $config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user