chore: sync

This commit is contained in:
2026-02-09 00:05:29 +01:00
parent 0301716890
commit 6bfa3d4dda
23 changed files with 622 additions and 18 deletions

View File

@@ -6,9 +6,14 @@ CREATE TABLE IF NOT EXISTS users (
race_key TEXT NOT NULL DEFAULT 'human',
title TEXT NOT NULL DEFAULT '',
avatar_key TEXT NOT NULL DEFAULT 'default',
is_active BOOLEAN NOT NULL DEFAULT FALSE,
activation_token TEXT,
email_verified_at TIMESTAMP NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS users_activation_token_idx ON users (activation_token);
CREATE TABLE IF NOT EXISTS planets (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,

View File

@@ -4,8 +4,11 @@ declare(strict_types=1);
namespace App;
use App\Config\AppConfig;
use App\Config\ConfigLoader;
use App\Config\ConfigValidator;
use App\Module\Email\EmailSenderInterface;
use App\Module\Email\SmtpTransport;
use App\Database\ConnectionFactory;
use App\Module\Auth\Middleware\AuthContextMiddleware;
use App\Module\Auth\Service\AuthService;
@@ -37,7 +40,14 @@ final class Bootstrap
return new ConfigValidator();
},
ConfigLoader::class => function (ConfigValidator $validator) {
return new ConfigLoader($validator);
$configDir = dirname(__DIR__, 3) . '/config';
return ConfigLoader::fromDirectory($validator, $configDir);
},
AppConfig::class => function () {
return AppConfig::fromEnvironment();
},
EmailSenderInterface::class => function (AppConfig $config) {
return new SmtpTransport($config);
},
]);

View File

@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace App\Config;
final class AppConfig
{
private bool $devEntwicklung;
private bool $emailLogTransport;
private string $emailSmtpHost;
private int $emailSmtpPort;
private string $emailSmtpUsername;
private string $emailSmtpPassword;
private string $emailFromAddress;
private string $emailSmtpSecure;
private int $emailSmtpTimeout;
private string $emailLogPath;
private string $appUrl;
private function __construct(
bool $devEntwicklung,
bool $emailLogTransport,
string $emailSmtpHost,
int $emailSmtpPort,
string $emailSmtpUsername,
string $emailSmtpPassword,
string $emailFromAddress,
string $emailSmtpSecure,
int $emailSmtpTimeout,
string $emailLogPath,
string $appUrl
) {
$this->devEntwicklung = $devEntwicklung;
$this->emailLogTransport = $emailLogTransport;
$this->emailSmtpHost = $emailSmtpHost;
$this->emailSmtpPort = $emailSmtpPort;
$this->emailSmtpUsername = $emailSmtpUsername;
$this->emailSmtpPassword = $emailSmtpPassword;
$this->emailFromAddress = $emailFromAddress;
$this->emailSmtpSecure = $emailSmtpSecure;
$this->emailSmtpTimeout = $emailSmtpTimeout;
$this->emailLogPath = $emailLogPath;
$this->appUrl = rtrim($appUrl, '/');
}
public static function fromEnvironment(): self
{
$env = static fn (string $key, string $fallback = ''): string => getenv($key) ?: $fallback;
$port = (int)$env('EMAIL_SMTP_PORT', '587');
$timeout = (int)$env('EMAIL_SMTP_TIMEOUT', '30');
$secure = strtolower($env('EMAIL_SMTP_SECURE', 'tls'));
if (!in_array($secure, ['ssl', 'tls', 'none'], true)) {
$secure = 'tls';
}
return new self(
(bool)(int)$env('DEV_ENTWICKLUNG', '0'),
(bool)(int)$env('EMAIL_LOG_TRANSPORT', '0'),
$env('EMAIL_SMTP_HOST', 'localhost'),
$port,
$env('EMAIL_SMTP_USERNAME', ''),
$env('EMAIL_SMTP_PASSWORD', ''),
$env('EMAIL_FROM_ADDRESS', 'no-reply@example.test'),
$secure,
$timeout,
$env('EMAIL_LOG_PATH', 'storage/logs/email.log'),
$env('APP_URL', 'http://localhost')
);
}
public function isDevEntwicklung(): bool
{
return $this->devEntwicklung;
}
public function isEmailLogTransportEnabled(): bool
{
return $this->emailLogTransport;
}
public function setEmailLogTransport(bool $enabled): void
{
$this->emailLogTransport = $enabled;
}
public function getEmailSmtpHost(): string
{
return $this->emailSmtpHost;
}
public function getEmailSmtpPort(): int
{
return $this->emailSmtpPort;
}
public function getEmailSmtpUsername(): string
{
return $this->emailSmtpUsername;
}
public function getEmailSmtpPassword(): string
{
return $this->emailSmtpPassword;
}
public function getEmailFromAddress(): string
{
return $this->emailFromAddress;
}
public function getEmailSmtpSecure(): string
{
return $this->emailSmtpSecure;
}
public function getEmailSmtpTimeout(): int
{
return $this->emailSmtpTimeout;
}
public function getEmailLogPath(): string
{
return $this->emailLogPath;
}
public function getAppUrl(): string
{
return $this->appUrl;
}
}

View File

@@ -8,11 +8,43 @@ final class ConfigLoader
{
private array $config;
public function __construct(array $config)
private function __construct(array $config)
{
$this->config = $config;
}
public static function fromArray(array $config): self
{
return new self($config);
}
public static function fromDirectory(ConfigValidator $validator, string $configDir): self
{
$definitions = [
'blueprints_buildings' => 'validateBlueprintsBuildings',
'auto_cost_weights' => null,
'planet_classes' => 'validatePlanetClasses',
'races' => 'validateRaces',
'avatars' => 'validateAvatars',
];
$config = [];
foreach ($definitions as $file => $validatorMethod) {
$path = rtrim($configDir, '\\/') . '/' . $file . '.json';
if (!is_file($path)) {
continue;
}
$payload = json_decode((string)file_get_contents($path), true);
if (!is_array($payload)) {
continue;
}
if ($validatorMethod !== null && method_exists($validator, $validatorMethod)) {
$validator->{$validatorMethod}($payload, $path);
}
$config[$file] = $payload;
}
return new self($config);
}
public function blueprintsBuildings(): array
{
return $this->config['blueprints_buildings'] ?? [];
@@ -22,4 +54,19 @@ final class ConfigLoader
{
return $this->config['auto_cost_weights'] ?? [];
}
public function planetClasses(): array
{
return $this->config['planet_classes'] ?? [];
}
public function races(): array
{
return $this->config['races'] ?? ['races' => []];
}
public function avatars(): array
{
return $this->config['avatars'] ?? ['avatars' => []];
}
}

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Module\Auth\Controller;
use App\Config\ConfigLoader;
use App\Config\AppConfig;
use App\Module\Email\EmailSenderInterface;
use App\Module\PlanetGenerator\Service\PlanetGenerator;
use App\Shared\Http\JsonResponder;
use PDO;
@@ -24,7 +26,9 @@ final class AuthController
public function __construct(
private PDO $pdo,
private ConfigLoader $configLoader,
private PlanetGenerator $planetGenerator
private PlanetGenerator $planetGenerator,
private EmailSenderInterface $emailSender,
private AppConfig $appConfig
) {
}
@@ -144,8 +148,8 @@ final class AuthController
]);
}
public function registerStep3(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
public function registerStep3(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$draft = $this->getDraft();
if (empty($draft['race_key']) || empty($draft['avatar_key']) || empty($draft['title'])) {
return JsonResponder::withJson($response, [
@@ -194,12 +198,14 @@ final class AuthController
], 409);
}
$token = bin2hex(random_bytes(16));
$isDev = $this->appConfig->isDevEntwicklung();
try {
$this->pdo->beginTransaction();
$stmt = $this->pdo->prepare(
'INSERT INTO users (username, email, password_hash, race_key, title, avatar_key)
VALUES (:username, :email, :password_hash, :race_key, :title, :avatar_key)
'INSERT INTO users (username, email, password_hash, race_key, title, avatar_key, activation_token, is_active)
VALUES (:username, :email, :password_hash, :race_key, :title, :avatar_key, :activation_token, :is_active)
RETURNING *'
);
$stmt->execute([
@@ -209,12 +215,14 @@ final class AuthController
'race_key' => $draft['race_key'],
'title' => $draft['title'],
'avatar_key' => $draft['avatar_key'],
'activation_token' => $token,
'is_active' => $isDev,
]);
$user = $stmt->fetch();
$userId = (int)($user['id'] ?? 0);
if ($userId <= 0) {
throw new \\RuntimeException('User-ID fehlt.');
throw new \RuntimeException('User-ID fehlt.');
}
$this->assignRole($userId, 'player');
@@ -233,7 +241,7 @@ final class AuthController
'error' => 'registration_failed',
'message' => 'Registrierung fehlgeschlagen.',
], 500);
} catch (\\Throwable $e) {
} catch (\Throwable $e) {
$this->pdo->rollBack();
return JsonResponder::withJson($response, [
'error' => 'registration_failed',
@@ -242,11 +250,70 @@ final class AuthController
}
$this->clearDraft();
if (!$isDev) {
$this->sendConfirmationEmail($email, $token);
return JsonResponder::withJson($response, [
'status' => 'pending',
'message' => 'Bestätige deine E-Mail-Adresse via Link.',
], 201);
}
$this->loginUser((int)$user['id']);
return JsonResponder::withJson($response, [
'user' => $this->buildUserSummary($user),
'status' => 'active',
], 201);
}
public function confirmRegistration(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$body = $this->parseBody($request);
$token = trim((string)($body['token'] ?? ''));
if ($token === '') {
return JsonResponder::withJson($response, [
'error' => 'invalid_token',
'message' => 'Token fehlt.',
], 400);
}
$stmt = $this->pdo->prepare('SELECT * FROM users WHERE activation_token = :token LIMIT 1');
$stmt->execute(['token' => $token]);
$user = $stmt->fetch();
if (!$user) {
return JsonResponder::withJson($response, [
'error' => 'invalid_token',
'message' => 'Ungültiges Token.',
], 404);
}
if ((bool)($user['is_active'] ?? false)) {
return JsonResponder::withJson($response, [
'error' => 'already_active',
'message' => 'Account bereits aktiviert.',
], 409);
}
$stmt = $this->pdo->prepare(
'UPDATE users SET is_active = TRUE, email_verified_at = :verified, activation_token = NULL WHERE id = :id'
);
$stmt->execute([
'verified' => (new \DateTimeImmutable('now'))->format('Y-m-d H:i:s'),
'id' => (int)$user['id'],
]);
$this->loginUser((int)$user['id']);
return JsonResponder::withJson($response, [
'user' => $this->buildUserSummary($user),
], 201);
]);
}
private function sendConfirmationEmail(string $to, string $token): void
{
$url = $this->appConfig->getAppUrl();
$link = rtrim($url, '/') . '/auth/register/confirm?token=' . urlencode($token);
$body = "Bitte bestätige deinen Account:\n" . $link;
$this->emailSender->sendEmail($to, 'E-Mail-Adresse bestätigen', $body);
}
/**

View File

@@ -23,6 +23,7 @@ final class Routes
$group->post('/auth/register/step1', [$auth, 'registerStep1']);
$group->post('/auth/register/step2', [$auth, 'registerStep2']);
$group->post('/auth/register/step3', [$auth, 'registerStep3']);
$group->post('/auth/register/confirm', [$auth, 'confirmRegistration']);
$group->get('/meta/races', [$meta, 'races']);
$group->get('/meta/avatars', [$meta, 'avatars']);

View File

@@ -4,12 +4,13 @@ declare(strict_types=1);
namespace App\Module\Auth\Service;
use App\Config\AppConfig;
use PDO;
use Psr\Http\Message\ServerRequestInterface;
final class AuthService
{
public function __construct(private PDO $pdo)
public function __construct(private PDO $pdo, private AppConfig $config)
{
}
@@ -26,23 +27,41 @@ final class AuthService
$sessionUserId = $this->getSessionUserId();
if ($sessionUserId !== null) {
return $this->findUserById($sessionUserId);
$sessionUser = $this->findUserById($sessionUserId);
if ($this->isUserAllowed($sessionUser)) {
return $sessionUser;
}
return null;
}
if ((int)(getenv('DEV_MODE') ?: 0) === 1) {
$devUserId = getenv('DEV_USER_ID');
if ($devUserId !== false && is_numeric($devUserId)) {
$user = $this->findUserById((int)$devUserId);
if ($user) {
if ($user && $this->isUserAllowed($user)) {
return $user;
}
}
return $this->findFirstUser();
$firstUser = $this->findFirstUser();
if ($this->isUserAllowed($firstUser)) {
return $firstUser;
}
}
return null;
}
private function isUserAllowed(?array $user): bool
{
if ($user === null) {
return false;
}
if ($this->config->isDevEntwicklung()) {
return true;
}
return (bool)($user['is_active'] ?? false);
}
private function extractUserId(ServerRequestInterface $request): ?int
{
$header = $request->getHeaderLine('X-User-Id');

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Module\Email;
interface EmailSenderInterface
{
public function sendEmail(string $to, string $subject, string $body): void;
public function enableLogTransport(bool $enabled): void;
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Module\Email;
use App\Config\AppConfig;
final class SmtpTransport implements EmailSenderInterface
{
private bool $logTransportEnabled;
public function __construct(private AppConfig $config)
{
$this->logTransportEnabled = $config->isEmailLogTransportEnabled();
}
public function sendEmail(string $to, string $subject, string $body): void
{
if ($this->logTransportEnabled) {
$this->log($to, $subject, $body);
return;
}
$transport = fsockopen($this->config->getEmailSmtpHost(), $this->config->getEmailSmtpPort(), $errno, $errstr, $this->config->getEmailSmtpTimeout());
if ($transport === false) {
throw new \RuntimeException("SMTP-Verbindung fehlgeschlagen: {$errno} {$errstr}");
}
stream_set_timeout($transport, $this->config->getEmailSmtpTimeout());
$secure = $this->config->getEmailSmtpSecure();
if ($secure === 'ssl') {
// Wartet auf SSL-Handshake
}
$this->write($transport, "HELO " . parse_url($this->config->getAppUrl(), PHP_URL_HOST) . "\r\n");
$this->write($transport, "AUTH LOGIN\r\n");
$this->write($transport, base64_encode($this->config->getEmailSmtpUsername()) . "\r\n");
$this->write($transport, base64_encode($this->config->getEmailSmtpPassword()) . "\r\n");
$this->write($transport, "MAIL FROM:<{$this->config->getEmailFromAddress()}>\r\n");
$this->write($transport, "RCPT TO:<{$to}>\r\n");
$this->write($transport, "DATA\r\n");
$message = "Subject: {$subject}\r\n";
$message .= "From: {$this->config->getEmailFromAddress()}\r\n";
$message .= "To: {$to}\r\n";
$message .= "\r\n";
$message .= $body . "\r\n.\r\n";
$this->write($transport, $message);
$this->write($transport, "QUIT\r\n");
fclose($transport);
}
public function enableLogTransport(bool $enabled): void
{
$this->logTransportEnabled = $enabled;
}
private function write($transport, string $message): void
{
fwrite($transport, $message);
fflush($transport);
$response = fgets($transport);
if ($response === false) {
throw new \RuntimeException('SMTP: Keine Antwort vom Server.');
}
$code = (int)substr($response, 0, 3);
if ($code >= 400) {
throw new \RuntimeException("SMTP-Fehler {$code}: {$response}");
}
}
private function log(string $to, string $subject, string $body): void
{
$path = $this->config->getEmailLogPath();
$now = (new \DateTimeImmutable('now'))->format('Y-m-d H:i:s');
$entry = "[{$now}] TO: {$to} SUBJECT: {$subject}\n{$body}\n\n";
file_put_contents($path, $entry, FILE_APPEND | LOCK_EX);
}
}

View File

@@ -58,7 +58,20 @@ final class AuthFlowTest extends TestCase
], $cookie);
$response3 = $app->handle($step3);
self::assertSame(201, $response3->getStatusCode());
$cookie = $this->extractCookie($response3, $cookie);
$body3 = json_decode((string)$response3->getBody(), true);
self::assertSame('pending', $body3['status']);
// confirm registration
$pdo->exec("UPDATE users SET activation_token = 'confirm-token' WHERE username = 'neo'");
$confirm = $this->jsonRequest($factory, 'POST', '/auth/register/confirm', [
'token' => 'confirm-token',
], $cookie);
$confirmResponse = $app->handle($confirm);
self::assertSame(200, $confirmResponse->getStatusCode());
$confirmMeRequest = $factory->createServerRequest('POST', '/auth/register/confirm', ['token' => 'confirm-token']);
$confirmMeRequest = $confirmMeRequest->withHeader('Cookie', $this->extractCookie($response3, $cookie));
$confirmMeResponse = $app->handle($confirmMeRequest);
self::assertSame(200, $confirmMeResponse->getStatusCode());
$meRequest = $factory->createServerRequest('GET', '/me');
if ($cookie) {

View File

@@ -19,7 +19,7 @@ final class TestAppFactory
$container = Bootstrap::buildContainer([
\PDO::class => $pdo,
TimeProvider::class => $timeProvider,
ConfigLoader::class => new ConfigLoader(new ConfigValidator(), $repoRoot . '/config'),
ConfigLoader::class => ConfigLoader::fromDirectory(new ConfigValidator(), $repoRoot . '/config'),
]);
return Bootstrap::createApp($container);

View File

@@ -15,7 +15,7 @@ final class AutoCostCalculatorTest extends TestCase
'capacity_add' => 3,
],
];
$configLoader = new ConfigLoader($config);
$configLoader = ConfigLoader::fromArray($config);
$calculator = new AutoCostCalculator($configLoader);
$blueprint = [
@@ -54,4 +54,4 @@ final class AutoCostCalculatorTest extends TestCase
$this->assertEquals(100, $result['cost']);
$this->assertEquals(60, $result['build_time']);
}
}
}