diff --git a/.gitignore b/.gitignore index 6b0cf92..82d4029 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,10 @@ /vendor/ ###< symfony/framework-bundle ### +# Written by app:apikeys:add +/.env.local.temp +/.env.local.backup + ###> friendsofphp/php-cs-fixer ### /.php-cs-fixer.php /.php-cs-fixer.cache diff --git a/CHANGELOG.md b/CHANGELOG.md index f63ea1c..992ac5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,11 @@ See [keep a changelog] for information about writing changes to this log. - [PR-27](https://github.com/itk-dev/event-database-api/pull/27) Add Claude Code project setup (CLAUDE.md, agents, skills) +## [1.2.3] - 2026-10-07 + +- [PR-63](https://github.com/itk-dev/event-database-api/pull/63) + Add `app:apikeys:add` command to generate API keys + ## [1.2.2] - 2026-05-22 - [PR-26](https://github.com/itk-dev/event-database-api/pull/26) diff --git a/README.md b/README.md index 61e4291..a2004db 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,18 @@ APP_API_KEYS='[ ]' ``` +To generate a new API key for a user and add it to `.env.local`, run: + +``` shell +docker compose exec phpfpm bin/console app:apikeys:add user_3 +``` + +If the username is omitted, the command prompts for it. The key is printed so it can be sent to the user. If +`.env.local.php` exists, `composer dump-env prod` must be run for the new key to take effect. + +The command writes `.env.local.temp`, verifies it, copies `.env.local` to `.env.local.backup` and then replaces +`.env.local`. It offers to delete the backup afterwards and refuses to run while a backup or temp file exists. + ## Production When installing composer and Symfony based application in production, you should not install development packages, diff --git a/src/Command/ApiKeysAddCommand.php b/src/Command/ApiKeysAddCommand.php new file mode 100644 index 0000000..04d8262 --- /dev/null +++ b/src/Command/ApiKeysAddCommand.php @@ -0,0 +1,189 @@ +addArgument('username', InputArgument::OPTIONAL, 'Username to create an API key for (prompted for if omitted)'); + } + + protected function interact(InputInterface $input, OutputInterface $output): void + { + if ('' !== trim((string) $input->getArgument('username'))) { + return; + } + + $question = new Question('Username to create an API key for: '); + $question->setValidator(function (?string $answer): string { + if ('' === trim((string) $answer)) { + throw new \RuntimeException('Username must not be empty.'); + } + + return trim((string) $answer); + }); + + /** @var QuestionHelper $helper */ + $helper = $this->getHelper('question'); + $input->setArgument('username', $helper->ask($input, $output, $question)); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $username = trim((string) $input->getArgument('username')); + $envFile = $this->projectDir.'/.env.local'; + $tempFile = $envFile.'.temp'; + $backupFile = $envFile.'.backup'; + + if ('' === $username) { + $io->error('Username must not be empty.'); + + return Command::FAILURE; + } + + if (is_link($envFile)) { + $io->error(sprintf('%s is a symlink. Edit the target file by hand instead.', $envFile)); + + return Command::FAILURE; + } + + if (file_exists($backupFile) || is_link($backupFile)) { + $io->error(sprintf('%s exists from a previous run. Check it and delete it, then retry.', $backupFile)); + + return Command::FAILURE; + } + + $original = file_exists($envFile) ? (string) file_get_contents($envFile) : ''; + + try { + $apikeys = $this->parseApiKeys($original); + } catch (FormatException|\JsonException $e) { + $io->error(sprintf('Could not read %s from %s: %s', self::ENV_VAR, $envFile, $e->getMessage())); + + return Command::FAILURE; + } + + if (in_array($username, array_column($apikeys, 'username'), true)) { + $io->error(sprintf('User "%s" already has an API key.', $username)); + + return Command::FAILURE; + } + + $apikey = bin2hex(random_bytes(32)); + $updated = [...$apikeys, ['username' => $username, 'apikey' => $apikey]]; + + // JSON_HEX_APOS keeps the value safe inside single quotes. + $line = self::ENV_VAR."='".json_encode($updated, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_HEX_APOS)."'"; + if (1 === preg_match(self::ENV_LINE_PATTERN, $original)) { + $contents = (string) preg_replace_callback(self::ENV_LINE_PATTERN, fn (): string => $line, $original, 1); + } else { + $contents = $original.('' === $original || str_ends_with($original, "\n") ? '' : "\n").$line."\n"; + } + + // Mode 'x' fails if the file exists, which also stops concurrent runs. + $handle = @fopen($tempFile, 'x'); + if (false === $handle) { + $io->error(sprintf('Could not create %s. If it is left over from a previous run, delete it and retry.', $tempFile)); + + return Command::FAILURE; + } + $written = fwrite($handle, $contents) === strlen($contents); + $written = fclose($handle) && $written; + + // Verify what landed on disk before .env.local is touched. + try { + $written = $written && $this->parseApiKeys((string) file_get_contents($tempFile)) === $updated; + } catch (FormatException|\JsonException) { + $written = false; + } + + if (!$written) { + unlink($tempFile); + $io->error(sprintf('Failed to write %s, %s is unchanged.', $tempFile, $envFile)); + + return Command::FAILURE; + } + + $hasBackup = file_exists($envFile); + if ($hasBackup && (!chmod($tempFile, fileperms($envFile) & 0777) || !copy($envFile, $backupFile))) { + @unlink($tempFile); + @unlink($backupFile); + $io->error(sprintf('Failed to back up %s, the file is unchanged.', $envFile)); + + return Command::FAILURE; + } + + // Atomic replace, .env.local never goes missing. + if (!rename($tempFile, $envFile)) { + @unlink($tempFile); + @unlink($backupFile); + $io->error(sprintf('Failed to replace %s, the file is unchanged.', $envFile)); + + return Command::FAILURE; + } + + $io->success(sprintf('API key for "%s" added to %s', $username, $envFile)); + $io->writeln($apikey); + + $compiledFile = $this->projectDir.'/.env.local.php'; + if (file_exists($compiledFile)) { + $io->warning(sprintf('%s exists and takes precedence over .env.local. Run "composer dump-env prod" for the new key to take effect.', $compiledFile)); + + $compiled = (include $compiledFile)[self::ENV_VAR] ?? '[]'; + if (json_decode((string) $compiled, true) !== $apikeys) { + $io->warning(sprintf('%s in %s differs from .env.local. Reconcile them before running "composer dump-env prod", or keys may be lost.', self::ENV_VAR, $compiledFile)); + } + } + + if ($hasBackup && $io->confirm(sprintf('Delete %s?', $backupFile), true)) { + unlink($backupFile); + } + + return Command::SUCCESS; + } + + /** + * @return list + * + * @throws FormatException + * @throws \JsonException + */ + private function parseApiKeys(string $contents): array + { + $value = (new Dotenv())->parse($contents)[self::ENV_VAR] ?? '[]'; + + return json_decode($value, true, 512, JSON_THROW_ON_ERROR); + } +} diff --git a/tests/Command/ApiKeysAddCommandTest.php b/tests/Command/ApiKeysAddCommandTest.php new file mode 100644 index 0000000..6f8bdec --- /dev/null +++ b/tests/Command/ApiKeysAddCommandTest.php @@ -0,0 +1,184 @@ +dir = sys_get_temp_dir().'/apikeys-'.bin2hex(random_bytes(4)); + mkdir($this->dir); + } + + protected function tearDown(): void + { + array_map(unlink(...), (array) glob($this->dir.'/{,.}*.*', GLOB_BRACE)); + rmdir($this->dir); + } + + public function testCreatesEnvLocalWhenMissing(): void + { + $tester = $this->runCommand('user_1'); + + $this->assertSame(Command::SUCCESS, $tester->getStatusCode()); + $keys = $this->keys(); + $this->assertCount(1, $keys); + $this->assertSame('user_1', $keys[0]['username']); + $this->assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $keys[0]['apikey']); + $this->assertStringContainsString($keys[0]['apikey'], $tester->getDisplay()); + } + + public function testReplacesMultiLineValueAndKeepsOtherLines(): void + { + file_put_contents($this->dir.'/.env.local', <<<'ENV' + INDEX_URL=http://elasticsearch:9200 + APP_API_KEYS='[ + {"username": "user_1", "apikey": "api_key_1"} + ]' + OTHER=value + ENV); + chmod($this->dir.'/.env.local', 0600); + + $this->assertSame(Command::SUCCESS, $this->runCommand("o'brien")->getStatusCode()); + $this->assertSame(0600, fileperms($this->dir.'/.env.local') & 0777); + $this->assertFileDoesNotExist($this->dir.'/.env.local.backup'); + $this->assertFileDoesNotExist($this->dir.'/.env.local.temp'); + + $env = (new Dotenv())->parse((string) file_get_contents($this->dir.'/.env.local')); + $this->assertSame('http://elasticsearch:9200', $env['INDEX_URL']); + $this->assertSame('value', $env['OTHER']); + $keys = $this->keys(); + $this->assertSame(['username' => 'user_1', 'apikey' => 'api_key_1'], $keys[0]); + $this->assertSame("o'brien", $keys[1]['username']); + } + + public function testPromptsForUsernameWhenOmitted(): void + { + $command = new ApiKeysAddCommand($this->dir); + $command->setHelperSet(new HelperSet([new QuestionHelper()])); + $tester = new CommandTester($command); + $tester->setInputs(['', 'user_1']); + $tester->execute([]); + + $this->assertSame(Command::SUCCESS, $tester->getStatusCode()); + $this->assertStringContainsString('Username must not be empty.', $tester->getDisplay()); + $this->assertSame('user_1', $this->keys()[0]['username']); + } + + public function testFailsWithoutUsernameWhenNonInteractive(): void + { + $tester = new CommandTester(new ApiKeysAddCommand($this->dir)); + $tester->execute([], ['interactive' => false]); + + $this->assertSame(Command::FAILURE, $tester->getStatusCode()); + $this->assertFileDoesNotExist($this->dir.'/.env.local'); + } + + public function testRejectsDuplicateUsername(): void + { + $original = "APP_API_KEYS='[{\"username\": \"user_1\", \"apikey\": \"api_key_1\"}]'\n"; + file_put_contents($this->dir.'/.env.local', $original); + + $this->assertSame(Command::FAILURE, $this->runCommand('user_1')->getStatusCode()); + $this->assertSame($original, file_get_contents($this->dir.'/.env.local')); + } + + public function testLeavesFileUntouchedWhenRewriteBreaksIt(): void + { + // A trailing comment after a multi-line value defeats the line pattern. + $original = "APP_API_KEYS='[\n{\"username\": \"user_1\", \"apikey\": \"api_key_1\"}\n]' # comment\n"; + file_put_contents($this->dir.'/.env.local', $original); + + $this->assertSame(Command::FAILURE, $this->runCommand('user_2')->getStatusCode()); + $this->assertSame($original, file_get_contents($this->dir.'/.env.local')); + $this->assertFileDoesNotExist($this->dir.'/.env.local.temp'); + $this->assertFileDoesNotExist($this->dir.'/.env.local.backup'); + } + + public function testKeepsBackupWhenDeclined(): void + { + $original = "APP_API_KEYS='[]'\n"; + file_put_contents($this->dir.'/.env.local', $original); + + $this->assertSame(Command::SUCCESS, $this->runCommand('user_1', ['no'])->getStatusCode()); + $this->assertSame($original, file_get_contents($this->dir.'/.env.local.backup')); + $this->assertSame('user_1', $this->keys()[0]['username']); + } + + public function testFailsWhenBackupExists(): void + { + $original = "APP_API_KEYS='[]'\n"; + file_put_contents($this->dir.'/.env.local', $original); + file_put_contents($this->dir.'/.env.local.backup', 'old'); + + $this->assertSame(Command::FAILURE, $this->runCommand('user_1')->getStatusCode()); + $this->assertSame($original, file_get_contents($this->dir.'/.env.local')); + $this->assertSame('old', file_get_contents($this->dir.'/.env.local.backup')); + } + + public function testFailsWhenTempFileExists(): void + { + $original = "APP_API_KEYS='[]'\n"; + file_put_contents($this->dir.'/.env.local', $original); + file_put_contents($this->dir.'/.env.local.temp', 'other run'); + + $this->assertSame(Command::FAILURE, $this->runCommand('user_1')->getStatusCode()); + $this->assertSame($original, file_get_contents($this->dir.'/.env.local')); + $this->assertSame('other run', file_get_contents($this->dir.'/.env.local.temp')); + } + + public function testFailsWhenEnvLocalIsSymlink(): void + { + $original = "APP_API_KEYS='[]'\n"; + file_put_contents($this->dir.'/target.env', $original); + symlink($this->dir.'/target.env', $this->dir.'/.env.local'); + + $this->assertSame(Command::FAILURE, $this->runCommand('user_1')->getStatusCode()); + $this->assertTrue(is_link($this->dir.'/.env.local')); + $this->assertSame($original, file_get_contents($this->dir.'/target.env')); + } + + public function testAdvisesDumpEnvWhenCompiledFileExists(): void + { + file_put_contents($this->dir.'/.env.local.php', " '[{\"username\":\"other\",\"apikey\":\"k\"}]'];\n"); + + $display = (string) preg_replace('/\s+/', ' ', $this->runCommand('user_1')->getDisplay()); + + $this->assertStringContainsString('composer dump-env prod', $display); + $this->assertStringContainsString('differs from .env.local', $display); + } + + /** + * @param list $answers runs non-interactively (default answers) when empty + */ + private function runCommand(string $username, array $answers = []): CommandTester + { + $tester = new CommandTester(new ApiKeysAddCommand($this->dir)); + $tester->setInputs($answers); + $tester->execute(['username' => $username], ['interactive' => [] !== $answers]); + + return $tester; + } + + /** + * @return list + */ + private function keys(): array + { + $env = (new Dotenv())->parse((string) file_get_contents($this->dir.'/.env.local')); + + return json_decode((string) $env['APP_API_KEYS'], true, 512, JSON_THROW_ON_ERROR); + } +}