251 lines
8.2 KiB
PHP
251 lines
8.2 KiB
PHP
#!/usr/bin/env php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
/**
|
||
* Wartungs-CLI.
|
||
*
|
||
* php bin/ekdos migrate Schema anlegen oder fortschreiben
|
||
* php bin/ekdos user:create Benutzer anlegen (Passwort wird abgefragt)
|
||
* php bin/ekdos user:list Benutzer auflisten
|
||
* php bin/ekdos user:password <name> Passwort zuruecksetzen
|
||
* php bin/ekdos cache:flush n8n-Cache leeren
|
||
* php bin/ekdos check Postgres, Redis und n8n pruefen
|
||
*
|
||
* Der erste Administrator wird mit user:create angelegt. Es gibt bewusst keinen
|
||
* Bootstrap-Benutzer aus der Umgebung: ein Passwort in einer .env ist ein
|
||
* Passwort, das niemand mehr aendert.
|
||
*/
|
||
|
||
use Ekdos\Auth\AuthService;
|
||
use Ekdos\Bootstrap\Container;
|
||
use Ekdos\N8n\Cache;
|
||
use Ekdos\N8n\Client;
|
||
use Ekdos\N8n\Endpoints;
|
||
use Ekdos\Support\Config;
|
||
use Ekdos\Users\Role;
|
||
use Ekdos\Users\UserRepository;
|
||
|
||
require __DIR__ . '/../vendor/autoload.php';
|
||
|
||
$projectRoot = dirname(__DIR__);
|
||
$command = $argv[1] ?? 'help';
|
||
|
||
function out(string $line = ''): void
|
||
{
|
||
fwrite(STDOUT, $line . PHP_EOL);
|
||
}
|
||
|
||
function fail(string $line): never
|
||
{
|
||
fwrite(STDERR, $line . PHP_EOL);
|
||
exit(1);
|
||
}
|
||
|
||
/** Liest eine Eingabe ohne Bildschirmausgabe. */
|
||
function readSecret(string $prompt): string
|
||
{
|
||
fwrite(STDERR, $prompt);
|
||
|
||
if (DIRECTORY_SEPARATOR === '/' && function_exists('shell_exec')) {
|
||
shell_exec('stty -echo 2>/dev/null');
|
||
}
|
||
|
||
$value = trim((string) fgets(STDIN));
|
||
|
||
if (DIRECTORY_SEPARATOR === '/' && function_exists('shell_exec')) {
|
||
shell_exec('stty echo 2>/dev/null');
|
||
}
|
||
|
||
fwrite(STDERR, PHP_EOL);
|
||
|
||
return $value;
|
||
}
|
||
|
||
function ask(string $prompt, string $fallback = ''): string
|
||
{
|
||
fwrite(STDERR, $prompt);
|
||
$value = trim((string) fgets(STDIN));
|
||
|
||
return $value !== '' ? $value : $fallback;
|
||
}
|
||
|
||
$container = Container::build($projectRoot);
|
||
|
||
switch ($command) {
|
||
case 'migrate':
|
||
/** @var PDO $db */
|
||
$db = $container->get(PDO::class);
|
||
$db->exec('create table if not exists schema_migrations (version text primary key, applied_at timestamptz not null default now())');
|
||
|
||
$applied = $db->query('select version from schema_migrations')->fetchAll(PDO::FETCH_COLUMN);
|
||
$files = glob($projectRoot . '/migrations/*.sql') ?: [];
|
||
sort($files);
|
||
$ran = 0;
|
||
|
||
foreach ($files as $file) {
|
||
$version = basename($file, '.sql');
|
||
|
||
if (in_array($version, $applied, true)) {
|
||
continue;
|
||
}
|
||
|
||
$sql = file_get_contents($file);
|
||
|
||
if ($sql === false) {
|
||
fail('Migration nicht lesbar: ' . $file);
|
||
}
|
||
|
||
$db->beginTransaction();
|
||
|
||
try {
|
||
$db->exec($sql);
|
||
$statement = $db->prepare('insert into schema_migrations (version) values (:version)');
|
||
$statement->execute(['version' => $version]);
|
||
$db->commit();
|
||
} catch (Throwable $exception) {
|
||
$db->rollBack();
|
||
fail('Migration ' . $version . ' fehlgeschlagen: ' . $exception->getMessage());
|
||
}
|
||
|
||
out(' angewendet: ' . $version);
|
||
$ran++;
|
||
}
|
||
|
||
out($ran === 0 ? 'Das Schema ist aktuell.' : $ran . ' Migration(en) angewendet.');
|
||
break;
|
||
|
||
case 'user:create':
|
||
/** @var UserRepository $users */
|
||
$users = $container->get(UserRepository::class);
|
||
|
||
$username = ask('Benutzername: ');
|
||
$displayName = ask('Anzeigename: ', $username);
|
||
$roleInput = ask('Rolle [admin|inhaber|buero] (admin): ', 'admin');
|
||
$role = Role::tryFrom($roleInput) ?? fail('Unbekannte Rolle: ' . $roleInput);
|
||
|
||
if ($username === '') {
|
||
fail('Der Benutzername darf nicht leer sein.');
|
||
}
|
||
|
||
if ($users->usernameTaken($username)) {
|
||
fail('Dieser Benutzername ist bereits vergeben.');
|
||
}
|
||
|
||
$password = readSecret('Passwort: ');
|
||
|
||
if (($problem = AuthService::rejectWeakPassword($password)) !== null) {
|
||
fail($problem);
|
||
}
|
||
|
||
if ($password !== readSecret('Passwort wiederholen: ')) {
|
||
fail('Die Passwörter stimmen nicht überein.');
|
||
}
|
||
|
||
$user = $users->create($username, $displayName, $role, AuthService::hash($password));
|
||
$users->audit(null, 'CLI', 'user.create', $user->id, $user->username, ['role' => $role->value]);
|
||
out('Angelegt: ' . $user->username . ' (' . $user->role->label() . ')');
|
||
break;
|
||
|
||
case 'user:list':
|
||
/** @var UserRepository $users */
|
||
$users = $container->get(UserRepository::class);
|
||
$rows = $users->all();
|
||
|
||
if ($rows === []) {
|
||
out('Es ist noch kein Benutzer angelegt. Anlegen mit: php bin/ekdos user:create');
|
||
break;
|
||
}
|
||
|
||
out(sprintf('%-22s %-24s %-14s %-8s %s', 'BENUTZER', 'NAME', 'ROLLE', 'AKTIV', 'LETZTE ANMELDUNG'));
|
||
|
||
foreach ($rows as $user) {
|
||
out(sprintf(
|
||
'%-22s %-24s %-14s %-8s %s',
|
||
$user->username,
|
||
$user->displayName,
|
||
$user->role->value,
|
||
$user->isActive ? 'ja' : 'nein',
|
||
$user->lastLoginAt ?? '–',
|
||
));
|
||
}
|
||
break;
|
||
|
||
case 'user:password':
|
||
/** @var UserRepository $users */
|
||
$users = $container->get(UserRepository::class);
|
||
$username = $argv[2] ?? fail('Aufruf: php bin/ekdos user:password <benutzername>');
|
||
$user = $users->findByUsernameWithHash($username) ?? fail('Unbekannter Benutzer: ' . $username);
|
||
|
||
$password = readSecret('Neues Passwort für ' . $user->username . ': ');
|
||
|
||
if (($problem = AuthService::rejectWeakPassword($password)) !== null) {
|
||
fail($problem);
|
||
}
|
||
|
||
$users->update($user->id, ['password_hash' => AuthService::hash($password)]);
|
||
$container->get(\Ekdos\Auth\SessionStore::class)->destroyAllFor($user->id);
|
||
$users->audit(null, 'CLI', 'user.password', $user->id, $user->username);
|
||
out('Passwort geändert. Offene Sitzungen dieses Benutzers wurden beendet.');
|
||
break;
|
||
|
||
case 'cache:flush':
|
||
/** @var Cache $cache */
|
||
$cache = $container->get(Cache::class);
|
||
out($cache->flushAll() . ' zwischengespeicherte n8n-Antworten verworfen.');
|
||
break;
|
||
|
||
case 'check':
|
||
/** @var Config $config */
|
||
$config = $container->get(Config::class);
|
||
$problems = 0;
|
||
|
||
try {
|
||
$container->get(PDO::class)->query('select 1');
|
||
out(' Postgres erreichbar');
|
||
} catch (Throwable $exception) {
|
||
out(' Postgres FEHLER: ' . $exception->getMessage());
|
||
$problems++;
|
||
}
|
||
|
||
try {
|
||
$container->get(Redis::class)->ping();
|
||
out(' Redis erreichbar');
|
||
} catch (Throwable $exception) {
|
||
out(' Redis FEHLER: ' . $exception->getMessage());
|
||
$problems++;
|
||
}
|
||
|
||
/** @var Endpoints $endpoints */
|
||
$endpoints = $container->get(Endpoints::class);
|
||
/** @var Client $n8n */
|
||
$n8n = $container->get(Client::class);
|
||
$reply = $n8n->get($endpoints->openTickets());
|
||
|
||
if ($reply->status === 0) {
|
||
out(' n8n FEHLER: nicht erreichbar unter ' . $endpoints->openTickets());
|
||
$problems++;
|
||
} else {
|
||
out(' n8n HTTP ' . $reply->status . ' von ' . $endpoints->openTickets());
|
||
}
|
||
|
||
foreach (['N8N_CUSTOMER_KEY', 'N8N_INVOICE_SYNC_SECRET'] as $secret) {
|
||
if (!$config->has($secret)) {
|
||
out(' Hinweis ' . $secret . ' ist nicht gesetzt; betroffene Funktionen antworten mit 503.');
|
||
}
|
||
}
|
||
|
||
exit($problems === 0 ? 0 : 1);
|
||
|
||
default:
|
||
out('EK-DOS-WEB Wartungs-CLI');
|
||
out();
|
||
out(' php bin/ekdos migrate Schema anlegen oder fortschreiben');
|
||
out(' php bin/ekdos user:create Benutzer anlegen');
|
||
out(' php bin/ekdos user:list Benutzer auflisten');
|
||
out(' php bin/ekdos user:password <name> Passwort zurücksetzen');
|
||
out(' php bin/ekdos cache:flush n8n-Cache leeren');
|
||
out(' php bin/ekdos check Postgres, Redis und n8n prüfen');
|
||
}
|