This commit is contained in:
2026-02-03 09:18:15 +01:00
parent 13efe9406c
commit dc427490a5
42 changed files with 5104 additions and 65 deletions

View File

@@ -9,6 +9,7 @@ use App\Config\ConfigValidator;
use App\Database\ConnectionFactory;
use App\Module\Auth\Middleware\AuthContextMiddleware;
use App\Module\Auth\Service\AuthService;
use App\Module\Auth\Routes as AuthRoutes;
use App\Module\BuildQueue\Routes as BuildQueueRoutes;
use App\Module\Economy\Routes as EconomyRoutes;
use App\Module\PlanetGenerator\Routes as PlanetGeneratorRoutes;
@@ -60,6 +61,7 @@ final class Bootstrap
$app->addBodyParsingMiddleware();
$app->group('', function (RouteCollectorProxyInterface $group) use ($container) {
AuthRoutes::register($group, $container);
EconomyRoutes::register($group, $container);
BuildQueueRoutes::register($group, $container);
PlanetGeneratorRoutes::register($group, $container);

View File

@@ -48,6 +48,16 @@ final class ConfigLoader
});
}
/**
* @return array<string,mixed>
*/
public function avatars(): array
{
return $this->load('avatars.json', function (array $config): void {
$this->validator->validateAvatars($config, 'avatars.json');
});
}
/**
* @param callable(array<string,mixed>):void $validate
* @return array<string,mixed>

View File

@@ -177,6 +177,33 @@ final class ConfigValidator
}
}
/**
* @param array<string,mixed> $config
*/
public function validateAvatars(array $config, string $file): void
{
$errors = [];
if (!isset($config['avatars']) || !is_array($config['avatars'])) {
$errors[] = "'avatars' muss ein Array sein";
}
if (isset($config['avatars']) && is_array($config['avatars'])) {
foreach ($config['avatars'] as $idx => $avatar) {
if (!is_array($avatar)) {
$errors[] = "Avatar #{$idx} muss ein Objekt sein";
continue;
}
foreach (['key', 'label', 'image'] as $req) {
if (empty($avatar[$req]) || !is_string($avatar[$req])) {
$errors[] = "Avatar #{$idx}: '{$req}' fehlt";
}
}
}
}
if ($errors) {
throw new ConfigValidationException($file, $errors);
}
}
/**
* @return string[]
*/

View File

@@ -0,0 +1,475 @@
<?php
declare(strict_types=1);
namespace App\Module\Auth\Controller;
use App\Config\ConfigLoader;
use App\Module\PlanetGenerator\Service\PlanetGenerator;
use App\Shared\Http\JsonResponder;
use PDO;
use PDOException;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Slim\Psr7\Response;
final class AuthController
{
private const PASSWORD_MIN_LENGTH = 8;
private const TITLE_MIN_LENGTH = 2;
private const TITLE_MAX_LENGTH = 40;
private const USERNAME_MIN_LENGTH = 3;
private const USERNAME_MAX_LENGTH = 20;
public function __construct(
private PDO $pdo,
private ConfigLoader $configLoader,
private PlanetGenerator $planetGenerator
) {
}
public function login(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$body = $this->parseBody($request);
$identifier = trim((string)($body['username_or_email'] ?? ''));
$password = (string)($body['password'] ?? '');
if ($identifier === '' || $password === '') {
return JsonResponder::withJson($response, [
'error' => 'invalid_input',
'message' => 'Username/E-Mail und Passwort sind Pflichtfelder.',
], 400);
}
$user = $this->findUserByIdentifier($identifier);
if (!$user || !password_verify($password, (string)($user['password_hash'] ?? ''))) {
return JsonResponder::withJson(new Response(), [
'error' => 'invalid_credentials',
'message' => 'Login fehlgeschlagen.',
], 401);
}
$this->loginUser((int)$user['id']);
return JsonResponder::withJson($response, [
'user' => $this->buildUserSummary($user),
]);
}
public function logout(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$this->startSession();
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$params = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain'], $params['secure'], $params['httponly']);
}
session_destroy();
return JsonResponder::withJson($response, ['ok' => true]);
}
public function me(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$user = $request->getAttribute('user');
if (!is_array($user) || !isset($user['id'])) {
return JsonResponder::withJson(new Response(), [
'error' => 'auth_required',
'message' => 'Authentifizierung erforderlich.',
], 401);
}
return JsonResponder::withJson($response, [
'user' => $this->buildUserSummary($user),
]);
}
public function registerStep1(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$body = $this->parseBody($request);
$raceKey = trim((string)($body['race_key'] ?? ''));
$races = $this->configLoader->races();
$raceConfig = $races['races'][$raceKey] ?? null;
if ($raceKey === '' || !is_array($raceConfig)) {
return JsonResponder::withJson($response, [
'error' => 'invalid_race',
'message' => 'Ungültige Rasse.',
], 422);
}
$draft = $this->getDraft();
$draft['race_key'] = $raceKey;
$this->saveDraft($draft);
return JsonResponder::withJson($response, [
'draft' => $this->sanitizeDraft($draft),
]);
}
public function registerStep2(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$draft = $this->getDraft();
if (empty($draft['race_key'])) {
return JsonResponder::withJson($response, [
'error' => 'draft_missing',
'message' => 'Bitte zuerst eine Rasse wählen.',
], 409);
}
$body = $this->parseBody($request);
$avatarKey = trim((string)($body['avatar_key'] ?? ''));
$title = $this->sanitizeTitle((string)($body['title'] ?? ''));
if ($avatarKey === '' || !$this->avatarExists($avatarKey)) {
return JsonResponder::withJson($response, [
'error' => 'invalid_avatar',
'message' => 'Ungültiger Avatar.',
], 422);
}
if (!$this->isValidTitle($title)) {
return JsonResponder::withJson($response, [
'error' => 'invalid_title',
'message' => 'Titel muss zwischen 2 und 40 Zeichen lang sein.',
], 422);
}
$draft['avatar_key'] = $avatarKey;
$draft['title'] = $title;
$this->saveDraft($draft);
return JsonResponder::withJson($response, [
'draft' => $this->sanitizeDraft($draft),
]);
}
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, [
'error' => 'draft_incomplete',
'message' => 'Bitte Registrierungsschritte 1 und 2 abschließen.',
], 409);
}
$body = $this->parseBody($request);
$username = trim((string)($body['username'] ?? ''));
$email = trim((string)($body['email'] ?? ''));
$password = (string)($body['password'] ?? '');
if (!$this->isValidUsername($username)) {
return JsonResponder::withJson($response, [
'error' => 'invalid_username',
'message' => 'Username muss zwischen 3 und 20 Zeichen lang sein.',
], 422);
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
return JsonResponder::withJson($response, [
'error' => 'invalid_email',
'message' => 'Ungültige E-Mail-Adresse.',
], 422);
}
if ($this->length($password) < self::PASSWORD_MIN_LENGTH) {
return JsonResponder::withJson($response, [
'error' => 'invalid_password',
'message' => 'Passwort zu kurz (mindestens 8 Zeichen).',
], 422);
}
$existing = $this->findUserByUsernameOrEmail($username, $email);
if ($existing['username'] ?? false) {
return JsonResponder::withJson($response, [
'error' => 'username_taken',
'message' => 'Username bereits vergeben.',
], 409);
}
if ($existing['email'] ?? false) {
return JsonResponder::withJson($response, [
'error' => 'email_taken',
'message' => 'E-Mail bereits registriert.',
], 409);
}
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)
RETURNING *'
);
$stmt->execute([
'username' => $username,
'email' => $this->lower($email),
'password_hash' => password_hash($password, PASSWORD_DEFAULT),
'race_key' => $draft['race_key'],
'title' => $draft['title'],
'avatar_key' => $draft['avatar_key'],
]);
$user = $stmt->fetch();
$userId = (int)($user['id'] ?? 0);
if ($userId <= 0) {
throw new \\RuntimeException('User-ID fehlt.');
}
$this->assignRole($userId, 'player');
$this->createStarterPlanet($userId);
$this->pdo->commit();
} catch (PDOException $e) {
$this->pdo->rollBack();
if ($e->getCode() === '23505') {
return JsonResponder::withJson($response, [
'error' => 'duplicate',
'message' => 'Username oder E-Mail bereits registriert.',
], 409);
}
return JsonResponder::withJson($response, [
'error' => 'registration_failed',
'message' => 'Registrierung fehlgeschlagen.',
], 500);
} catch (\\Throwable $e) {
$this->pdo->rollBack();
return JsonResponder::withJson($response, [
'error' => 'registration_failed',
'message' => 'Registrierung fehlgeschlagen.',
], 500);
}
$this->clearDraft();
$this->loginUser((int)$user['id']);
return JsonResponder::withJson($response, [
'user' => $this->buildUserSummary($user),
], 201);
}
/**
* @return array<string,mixed>
*/
private function parseBody(ServerRequestInterface $request): array
{
$body = $request->getParsedBody();
if (!is_array($body)) {
return [];
}
return $body;
}
/**
* @param array<string,mixed> $draft
* @return array<string,mixed>
*/
private function sanitizeDraft(array $draft): array
{
return [
'race_key' => $draft['race_key'] ?? null,
'avatar_key' => $draft['avatar_key'] ?? null,
'title' => $draft['title'] ?? null,
];
}
private function findUserByIdentifier(string $identifier): ?array
{
$stmt = $this->pdo->prepare('SELECT * FROM users WHERE username = :id OR LOWER(email) = :email');
$stmt->execute([
'id' => $identifier,
'email' => $this->lower($identifier),
]);
$user = $stmt->fetch();
return $user ?: null;
}
/**
* @return array{username:bool,email:bool}
*/
private function findUserByUsernameOrEmail(string $username, string $email): array
{
$stmt = $this->pdo->prepare('SELECT username, email FROM users WHERE username = :username OR email = :email');
$stmt->execute([
'username' => $username,
'email' => $this->lower($email),
]);
$rows = $stmt->fetchAll();
$result = ['username' => false, 'email' => false];
foreach ($rows as $row) {
if (isset($row['username']) && $row['username'] === $username) {
$result['username'] = true;
}
if (isset($row['email']) && $this->lower((string)$row['email']) === $this->lower($email)) {
$result['email'] = true;
}
}
return $result;
}
/**
* @param array<string,mixed> $user
* @return array<string,mixed>
*/
private function buildUserSummary(array $user): array
{
return [
'id' => (int)($user['id'] ?? 0),
'username' => (string)($user['username'] ?? ''),
'email' => (string)($user['email'] ?? ''),
'race_key' => (string)($user['race_key'] ?? ''),
'title' => (string)($user['title'] ?? ''),
'avatar_key' => (string)($user['avatar_key'] ?? ''),
];
}
private function avatarExists(string $avatarKey): bool
{
$config = $this->configLoader->avatars();
$avatars = $config['avatars'] ?? [];
foreach ($avatars as $avatar) {
if (is_array($avatar) && ($avatar['key'] ?? null) === $avatarKey) {
return true;
}
}
return false;
}
private function isValidTitle(string $title): bool
{
$len = $this->length($title);
return $len >= self::TITLE_MIN_LENGTH && $len <= self::TITLE_MAX_LENGTH;
}
private function sanitizeTitle(string $title): string
{
$clean = trim(strip_tags($title));
$clean = preg_replace('/\s+/', ' ', $clean) ?? '';
return $clean;
}
private function isValidUsername(string $username): bool
{
$len = $this->length($username);
if ($len < self::USERNAME_MIN_LENGTH || $len > self::USERNAME_MAX_LENGTH) {
return false;
}
return (bool)preg_match('/^[A-Za-z0-9_\-]+$/', $username);
}
private function length(string $value): int
{
if (function_exists('mb_strlen')) {
return mb_strlen($value);
}
return strlen($value);
}
private function lower(string $value): string
{
if (function_exists('mb_strtolower')) {
return mb_strtolower($value);
}
return strtolower($value);
}
private function startSession(): void
{
if (session_status() === PHP_SESSION_ACTIVE) {
return;
}
$secure = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
session_start([
'cookie_httponly' => true,
'cookie_samesite' => 'Lax',
'cookie_secure' => $secure,
'cookie_path' => '/',
]);
}
private function loginUser(int $userId): void
{
$this->startSession();
session_regenerate_id(true);
$_SESSION['user_id'] = $userId;
}
/**
* @return array<string,mixed>
*/
private function getDraft(): array
{
$this->startSession();
$draft = $_SESSION['reg_draft'] ?? [];
return is_array($draft) ? $draft : [];
}
/**
* @param array<string,mixed> $draft
*/
private function saveDraft(array $draft): void
{
$this->startSession();
$_SESSION['reg_draft'] = $draft;
}
private function clearDraft(): void
{
$this->startSession();
unset($_SESSION['reg_draft']);
}
private function assignRole(int $userId, string $roleKey): void
{
$stmt = $this->pdo->prepare(
'INSERT INTO user_roles (user_id, role_id)
SELECT :user_id, r.id FROM roles r WHERE r.key = :role_key
ON CONFLICT DO NOTHING'
);
$stmt->execute([
'user_id' => $userId,
'role_key' => $roleKey,
]);
}
private function createStarterPlanet(int $userId): void
{
$config = $this->configLoader->planetClasses();
$resources = [];
foreach (($config['resources'] ?? []) as $res) {
$resources[$res] = 500.0;
}
$seed = random_int(10, 999999);
$generated = $this->planetGenerator->generate('temperate', 'normal', $seed);
$stmt = $this->pdo->prepare(
'INSERT INTO planets (user_id, name, class_key, planet_seed, temperature_c, modifiers, resources, last_resource_update_at)
VALUES (:user_id, :name, :class_key, :planet_seed, :temperature_c, :modifiers, :resources, :last_update)
RETURNING id'
);
$stmt->execute([
'user_id' => $userId,
'name' => 'Heimatwelt',
'class_key' => $generated['class_key'],
'planet_seed' => $seed,
'temperature_c' => (int)$generated['temperature_c'],
'modifiers' => json_encode($generated['modifiers'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'resources' => json_encode($resources, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'last_update' => (new \DateTimeImmutable('now'))->format('Y-m-d H:i:s'),
]);
$planetId = (int)$stmt->fetchColumn();
$stmt = $this->pdo->prepare(
'INSERT INTO planet_buildings (planet_id, building_key, count, level)
VALUES (:planet_id, :building_key, :count, 0)
ON CONFLICT (planet_id, building_key) DO NOTHING'
);
$stmt->execute([
'planet_id' => $planetId,
'building_key' => 'build_center',
'count' => 1,
]);
}
}

View File

@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Module\Auth\Controller;
use App\Config\ConfigLoader;
use App\Shared\Http\JsonResponder;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
final class MetaController
{
public function __construct(private ConfigLoader $configLoader)
{
}
public function races(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$config = $this->configLoader->races();
$races = $config['races'] ?? [];
$items = [];
foreach ($races as $key => $race) {
if (!is_array($race)) {
continue;
}
$items[] = [
'key' => (string)$key,
'name' => (string)($race['name'] ?? $key),
'description' => (string)($race['description'] ?? ''),
'modifier_summary' => $this->buildModifierSummary($race['modifiers'] ?? []),
'modifiers' => $race['modifiers'] ?? [],
];
}
return JsonResponder::withJson($response, ['races' => $items]);
}
public function avatars(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$config = $this->configLoader->avatars();
$avatars = $config['avatars'] ?? [];
return JsonResponder::withJson($response, ['avatars' => $avatars]);
}
/**
* @param array<string,mixed> $modifiers
* @return string[]
*/
private function buildModifierSummary(array $modifiers): array
{
$summary = [];
$labels = [
'produce' => 'Produktion',
'consume' => 'Verbrauch',
];
$resourceNames = [
'metal' => 'Metall',
'alloy' => 'Legierung',
'crystals' => 'Kristall',
'energy' => 'Energie',
'credits' => 'Credits',
'population' => 'Bevölkerung',
'water' => 'Wasser',
'deuterium' => 'Deuterium',
'food' => 'Nahrung',
];
foreach ($labels as $section => $label) {
$entries = $modifiers[$section] ?? null;
if (!is_array($entries)) {
continue;
}
$parts = [];
foreach ($entries as $res => $val) {
if (!is_numeric($val) || (float)$val == 0.0) {
continue;
}
$percent = (float)$val * 100;
$sign = $percent >= 0 ? '+' : '';
$name = $resourceNames[$res] ?? (string)$res;
$parts[] = sprintf('%s %s%s%%', $name, $sign, $this->formatPercent($percent));
}
if ($parts) {
$summary[] = $label . ': ' . implode(', ', $parts);
}
}
return $summary;
}
private function formatPercent(float $value): string
{
$formatted = number_format($value, 1, ',', '');
if (str_ends_with($formatted, ',0')) {
return substr($formatted, 0, -2);
}
return $formatted;
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Module\Auth;
use App\Module\Auth\Controller\AuthController;
use App\Module\Auth\Controller\MetaController;
use Psr\Container\ContainerInterface;
use Slim\Interfaces\RouteCollectorProxyInterface;
final class Routes
{
public static function register(RouteCollectorProxyInterface $group, ContainerInterface $container): void
{
$auth = $container->get(AuthController::class);
$meta = $container->get(MetaController::class);
$group->post('/auth/login', [$auth, 'login']);
$group->post('/auth/logout', [$auth, 'logout']);
$group->get('/me', [$auth, 'me']);
$group->post('/auth/register/step1', [$auth, 'registerStep1']);
$group->post('/auth/register/step2', [$auth, 'registerStep2']);
$group->post('/auth/register/step3', [$auth, 'registerStep3']);
$group->get('/meta/races', [$meta, 'races']);
$group->get('/meta/avatars', [$meta, 'avatars']);
}
}

View File

@@ -18,11 +18,17 @@ final class AuthService
*/
public function resolveUser(ServerRequestInterface $request): ?array
{
$this->ensureSession();
$id = $this->extractUserId($request);
if ($id !== null) {
return $this->findUserById($id);
}
$sessionUserId = $this->getSessionUserId();
if ($sessionUserId !== null) {
return $this->findUserById($sessionUserId);
}
if ((int)(getenv('DEV_MODE') ?: 0) === 1) {
$devUserId = getenv('DEV_USER_ID');
if ($devUserId !== false && is_numeric($devUserId)) {
@@ -54,6 +60,32 @@ final class AuthService
return null;
}
private function getSessionUserId(): ?int
{
if (!isset($_SESSION['user_id'])) {
return null;
}
$val = $_SESSION['user_id'];
if (is_numeric($val)) {
return (int)$val;
}
return null;
}
private function ensureSession(): void
{
if (session_status() === PHP_SESSION_ACTIVE) {
return;
}
$secure = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
session_start([
'cookie_httponly' => true,
'cookie_samesite' => 'Lax',
'cookie_secure' => $secure,
'cookie_path' => '/',
]);
}
/**
* @return array<string,mixed>|null
*/