Skip to content
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
189 changes: 189 additions & 0 deletions src/Command/ApiKeysAddCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
<?php

declare(strict_types=1);

namespace App\Command;

use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Dotenv\Dotenv;
use Symfony\Component\Dotenv\Exception\FormatException;

#[AsCommand(
name: 'app:apikeys:add',
description: 'Generate an API key for a user and add it to APP_API_KEYS in .env.local',
)]
class ApiKeysAddCommand extends Command
{
private const string ENV_VAR = 'APP_API_KEYS';

// Matches the whole assignment, including multi-line quoted values.
private const string ENV_LINE_PATTERN = '/^APP_API_KEYS=(?:\'[^\']*\'|"(?:[^"\\\\]|\\\\.)*"|.*)$/m';

public function __construct(
#[Autowire('%kernel.project_dir%')]
private readonly string $projectDir,
) {
parent::__construct();
}

protected function configure(): void
{
$this->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<array{username: string, apikey: string}>
*
* @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);
}
}
Loading
Loading