diff --git a/.env.example b/.env.example index 59061b4..c0dec76 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,18 @@ APP_ENV=change-me +APP_URL=http://localhost:8000 DEV_MODE=change-me DEV_USER_ID=change-me +DEV_ENTWICKLUNG=0 +EMAIL_LOG_TRANSPORT=0 + +EMAIL_SMTP_HOST=change-me +EMAIL_SMTP_PORT=587 +EMAIL_SMTP_USERNAME=change-me +EMAIL_SMTP_PASSWORD=change-me +EMAIL_FROM_ADDRESS=no-reply@example.test +EMAIL_SMTP_SECURE=tls +EMAIL_SMTP_TIMEOUT=30 +EMAIL_LOG_PATH=storage/logs/email.log DB_HOST=change-me DB_PORT=change-me diff --git a/AGENTS.md b/AGENTS.md index 2e63166..8b19415 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,11 +7,16 @@ 4. **Keine Secrets:** Keine `.env`, keine echten Passwörter, keine API-Keys ins Repo. 5. **Default-Credentials:** Nur Platzhalter wie `change-me`/`your-strong-password` verwenden und dokumentieren, dass vor Prod geändert werden muss. 6. **Tests:** Tests müssen grün sein, bevor gemerged wird. +7. **Dokumentation:** Alle neuen Features, Funktionen und Module müssen unter `docs/wiki/` dokumentiert werden. Die Dokumentation muss aktuell, verständlich und vollständig sein. +8. **Commit-Disziplin:** Nach jedem neuen Feature oder Modul sofort committen; die Commit-Message benennt das Feature oder Modul ausdrücklich. + +9. **Orchestrator:** Zerlegt Aufgaben immer in kleinstmögliche Subtasks; Code-Agent-Subtasks sind auf maximal 2000 Tokens (Kontext + Instruktionen) begrenzt. ## Ordner-Intent (Kurzfassung) - `web/desktop` und `web/mobile`: Alles, was aus dem Webserver ausgeliefert wird. - `docs/`: Dokumentation (SSOT liegt hier). - `planning/`: Skizzen/Notizen, nicht für Deployment. +- ## Sprache - Alle Agenten-Ausgaben und Kommentare sind **Deutsch**, außer der User verlangt explizit eine andere Sprache. diff --git a/docker-compose.yml b/docker-compose.yml index 624d4bc..c14d2e7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,5 +10,32 @@ services: POSTGRES_PASSWORD: ${DB_PASS:-your-strong-password} volumes: - pgdata:/var/lib/postgresql/data + web: + image: php:8.2-apache + depends_on: + - db + ports: + - "8080:80" + environment: + APACHE_DOCUMENT_ROOT: /app/web/desktop/public + volumes: + - ./web/desktop/public:/app/web/desktop/public:ro + - ./web/mobile/public:/app/web/mobile/public:ro + - ./server:/app/server:ro + - ./config:/app/config:ro + command: + - /bin/sh + - -c + - | + cat <<'EOF' >/etc/apache2/conf-available/mobile-alias.conf + Alias /m /app/web/mobile/public + + Options Indexes FollowSymLinks + AllowOverride None + Require all granted + + EOF + ln -sf /etc/apache2/conf-available/mobile-alias.conf /etc/apache2/conf-enabled/mobile-alias.conf + apache2-foreground volumes: pgdata: diff --git a/docs/wiki/auth.md b/docs/wiki/auth.md new file mode 100644 index 0000000..4046cab --- /dev/null +++ b/docs/wiki/auth.md @@ -0,0 +1,28 @@ +## Auth Module + +### Module Description +Handles user authentication, session management, and permission checks across the system. + +### API Endpoints & Registration Flow + +1. **POST /auth/login** + - Purpose: Benutzerlogin über Session/Cookie + - Auth: Keine + - Payload: `{"username_or_email": "string", "password": "string"}` + - Response: `{"user": {...}}` + +2. **POST /auth/register/step1 ... /step3** + - Mehrstufige Registrierung (Rasse → Avatar/Titel → Username/Email/Passwort) + - Je Step wird der Draft gespeichert; Step3 erstellt Benutzer mit `activation_token` und nimmt `is_active=false` an, sofern `DEV_ENTWICKLUNG` nicht gesetzt ist. + - Response: `201` mit `{ "status": "pending" }` (E-Mail-Link) oder `{ "status": "active", "user": {...} }` (Dev-Bypass). + +3. **POST /auth/register/confirm** + - Purpose: Aktivierung per Token-Link (`token` im Body oder Query) + - Auth: Keine + - Response: `{"user": {...}}` nach erfolgreicher Aktivierung. + +4. **GET /auth/permissions** + - Purpose: Berechtigungen abrufen (Session erforderlich) + - Auth: Session + - Payload: Keine + - Response: `{"permissions": ["string"]}` diff --git a/docs/wiki/blueprints.md b/docs/wiki/blueprints.md new file mode 100644 index 0000000..d799383 --- /dev/null +++ b/docs/wiki/blueprints.md @@ -0,0 +1,18 @@ +## Blueprints Module + +### Module Description +Defines and manages construction blueprints for planetary structures and facilities. + +### API Endpoints + +- **GET /blueprints** + - *Purpose:* Retrieve list of available blueprints + - *Auth:* Required (JWT token) + - *Payload:* None + - *Response:* `{"blueprints": [{"id": "int", "name": "string", "resource_cost": {"ore": "int", "crystal": "int"}}]}` + +- **GET /blueprints/{id}** + - *Purpose:* Retrieve detailed blueprint information + - *Auth:* Required (JWT token) + - *Payload:* None + - *Response:* `{"id": "int", "name": "string", "description": "string", "resource_cost": {"ore": "int", "crystal": "int"}}` \ No newline at end of file diff --git a/docs/wiki/build_queue.md b/docs/wiki/build_queue.md new file mode 100644 index 0000000..4ee9386 --- /dev/null +++ b/docs/wiki/build_queue.md @@ -0,0 +1,24 @@ +## Build Queue Module + +### Module Description +Processes and manages construction queues for planetary structures and facilities. + +### API Endpoints + +- **GET /build/queue** + - *Purpose:* Retrieve current build queue + - *Auth:* Required (JWT token) + - *Payload:* None + - *Response:* `{"queue": [{"id": "int", "blueprint_id": "int", "status": "string"}]}` + +- **POST /build/start** + - *Purpose:* Start a new build + - *Auth:* Required (JWT token) + - *Payload:* `{"blueprint_id": "int", "planet_id": "int"}` + - *Response:* `{"build_id": "int", "status": "string"}` + +- **DELETE /build/cancel** + - *Purpose:* Cancel a build + - *Auth:* Required (JWT token) + - *Payload:* `{"build_id": "int"}` + - *Response:* `{"status": "string"}` \ No newline at end of file diff --git a/docs/wiki/economy.md b/docs/wiki/economy.md new file mode 100644 index 0000000..9fad27d --- /dev/null +++ b/docs/wiki/economy.md @@ -0,0 +1,18 @@ +## Economy Module + +### Module Description +Manages in-game economic transactions, resource management, and market interactions. + +### API Endpoints + +- **GET /economy/state** + - *Purpose:* Retrieve current economic state + - *Auth:* Required (JWT token) + - *Payload:* None + - *Response:* `{"credits": "int", "resources": {"ore": "int", "crystal": "int"}}` + +- **POST /economy/transaction** + - *Purpose:* Execute resource transaction + - *Auth:* Required (JWT token) + - *Payload:* `{"type": "string", "amount": "int", "target": "string"}` + - *Response:* `{"status": "string", "transaction_id": "string"}` \ No newline at end of file diff --git a/docs/wiki/index.md b/docs/wiki/index.md new file mode 100644 index 0000000..2f009cf --- /dev/null +++ b/docs/wiki/index.md @@ -0,0 +1,10 @@ +## Module Overview + +- [Auth Module](auth.md): Handles user authentication and permissions. +- [Economy Module](economy.md): Manages in-game economy and transactions. +- [Build Queue Module](build_queue.md): Processes and manages construction queues. +- [Planet Generator Module](planet_generator.md): Generates planetary environments. +- [Blueprints Module](blueprints.md): Defines and manages construction blueprints. +- [Permissions Module](permissions.md): Controls access and permissions across the system. + +For detailed information about each module, refer to their respective pages. \ No newline at end of file diff --git a/docs/wiki/permissions.md b/docs/wiki/permissions.md new file mode 100644 index 0000000..e8636c1 --- /dev/null +++ b/docs/wiki/permissions.md @@ -0,0 +1,24 @@ +## Permissions Module + +### Module Description +Controls access management and permission definitions across the system. + +### API Endpoints + +- **GET /permissions/list** + - *Purpose:* Retrieve all defined permissions + - *Auth:* Required (JWT token with `permission.read`) + - *Payload:* None + - *Response:* `{"permissions": ["string"]}` + +- **POST /permissions/grant** + - *Purpose:* Assign a permission to a user + - *Auth:* Required (JWT token with `permission.write`) + - *Payload:* `{"user_id": "int", "permission": "string"}` + - *Response:* `{"status": "string"}` + +- **DELETE /permissions/revoke** + - *Purpose:* Remove a permission from a user + - *Auth:* Required (JWT token with `permission.write`) + - *Payload:* `{"user_id": "int", "permission": "string"}` + - *Response:* `{"status": "string"}` \ No newline at end of file diff --git a/docs/wiki/planet_generator.md b/docs/wiki/planet_generator.md new file mode 100644 index 0000000..316a0bc --- /dev/null +++ b/docs/wiki/planet_generator.md @@ -0,0 +1,18 @@ +## Planet Generator Module + +### Module Description +Generates planetary environments with unique characteristics and resources. + +### API Endpoints + +- **GET /planet/generate** + - *Purpose:* Generate new planet data + - *Auth:* Required (JWT token with `planet.generate` permission) + - *Payload:* `{"class": "string", "size": "string"}` + - *Response:* `{"planet_id": "int", "resources": {"ore": "int", "crystal": "int"}, "climate": "string"}` + +- **GET /planet/detail** + - *Purpose:* Retrieve planet details + - *Auth:* Required (JWT token) + - *Payload:* `{"planet_id": "int"}` + - *Response:* `{"planet_id": "int", "class": "string", "resources": {"ore": "int", "crystal": "int"}, "climate": "string"}` \ No newline at end of file diff --git a/planning/rulebook_aufgaben.md b/planning/rulebook_aufgaben.md new file mode 100644 index 0000000..42ec1cb --- /dev/null +++ b/planning/rulebook_aufgaben.md @@ -0,0 +1,34 @@ +# Rulebook offene Punkte – Umsetzungs- & Doku-Plan + +Quelle/SSOT: `docs/game_rulebook_v1_2.md` + +## Ziele +- Rulebook-konforme Implementierung der fehlenden Mechaniken (Auto-Cost, Blueprint-Baukasten, Build-Queue). +- Vollständige, verständliche Entwickler-Dokumentation unter `docs/wiki/`. + +## Aufgaben (geordnet) +1. [ ] Auto-Cost-Score/Budget-Mechanik definieren (Rulebook §10) + - [ ] Score-Modell pro Effect-Typ festlegen (produce/consume/convert/capacity_add/queue_slots_add/points_add/modifier_add/grant_capability). + - [ ] Ressourcen-Werttabelle übernehmen und in Score-Formel einbauen. + - [ ] Budget pro Blueprint-Kategorie (building/ship/research/race/specialization) definieren. + - [ ] Ableitung von cost/build_time/upkeep (monoton) spezifizieren. + - [ ] UI-Breakdown-Format (transparente Scores) definieren. + +2. [ ] Blueprint-Effects/Requirements/Access-Regeln vollständig abbilden (Rulebook §7) + - [ ] Requirements: building_count, building_tag_count, research_level, has_capability, player_race_in/_not_in, player_spec_in/_not_in. + - [ ] Access: allowed_/blocked_races sowie allowed_/blocked_specializations. + - [ ] Capabilities/Unlocks: grant_capability & menu:* Mechanik dokumentieren. + +3. [ ] Build-Queue/Queue-Slots-Workflow definieren (Rulebook §8) + - [ ] Queue-Slots-Formel: base_slots + Σ(queue_slots_add). + - [ ] Job-Start-Flow: update_resources → slots → requirements/access → auto-cost → resource withdraw → job anlegen. + - [ ] Job-Finish-Flow: apply count/level, job entfernen, optional Event/Report. + - [ ] Destroyable-Flag/ Bombard-Handling (Rulebook §9) für Gebäude berücksichtigen. + +4. [ ] Dokumentation in `docs/wiki/` ergänzen + - [ ] Rulebook-Mechaniken als neue Seite (z. B. rulebook.md) oder Erweiterung bestehender Seiten dokumentieren. + - [ ] API-Seiten (blueprints/build_queue/economy/planet_generator) mit Regel- und Datenmodell-Details ergänzen. + - [ ] Verweise auf SSOT und Decision Log aufnehmen. + +## Notizen/Entscheidungen +- TBD diff --git a/server/db/migrations/001_init.sql b/server/db/migrations/001_init.sql index 674c1f9..8e88d33 100644 --- a/server/db/migrations/001_init.sql +++ b/server/db/migrations/001_init.sql @@ -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, diff --git a/server/src/Bootstrap.php b/server/src/Bootstrap.php index 92557f5..d211f1d 100644 --- a/server/src/Bootstrap.php +++ b/server/src/Bootstrap.php @@ -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); }, ]); diff --git a/server/src/Config/AppConfig.php b/server/src/Config/AppConfig.php new file mode 100644 index 0000000..e2c5059 --- /dev/null +++ b/server/src/Config/AppConfig.php @@ -0,0 +1,131 @@ +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; + } +} diff --git a/server/src/Config/ConfigLoader.php b/server/src/Config/ConfigLoader.php index dabb2a1..0cc3fbb 100644 --- a/server/src/Config/ConfigLoader.php +++ b/server/src/Config/ConfigLoader.php @@ -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' => []]; + } } diff --git a/server/src/Module/Auth/Controller/AuthController.php b/server/src/Module/Auth/Controller/AuthController.php index e679b96..65a9d3d 100644 --- a/server/src/Module/Auth/Controller/AuthController.php +++ b/server/src/Module/Auth/Controller/AuthController.php @@ -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); } /** diff --git a/server/src/Module/Auth/Routes.php b/server/src/Module/Auth/Routes.php index 3866c3d..a03fd22 100644 --- a/server/src/Module/Auth/Routes.php +++ b/server/src/Module/Auth/Routes.php @@ -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']); diff --git a/server/src/Module/Auth/Service/AuthService.php b/server/src/Module/Auth/Service/AuthService.php index a5e470c..5db58a4 100644 --- a/server/src/Module/Auth/Service/AuthService.php +++ b/server/src/Module/Auth/Service/AuthService.php @@ -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'); diff --git a/server/src/Module/Email/EmailSenderInterface.php b/server/src/Module/Email/EmailSenderInterface.php new file mode 100644 index 0000000..005f39d --- /dev/null +++ b/server/src/Module/Email/EmailSenderInterface.php @@ -0,0 +1,12 @@ +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); + } +} diff --git a/server/tests/Integration/AuthFlowTest.php b/server/tests/Integration/AuthFlowTest.php index dfe91a7..1783e6c 100644 --- a/server/tests/Integration/AuthFlowTest.php +++ b/server/tests/Integration/AuthFlowTest.php @@ -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) { diff --git a/server/tests/Support/TestAppFactory.php b/server/tests/Support/TestAppFactory.php index 880bd06..aca6e95 100644 --- a/server/tests/Support/TestAppFactory.php +++ b/server/tests/Support/TestAppFactory.php @@ -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); diff --git a/server/tests/Unit/AutoCostCalculatorTest.php b/server/tests/Unit/AutoCostCalculatorTest.php index aca71f7..d87a084 100644 --- a/server/tests/Unit/AutoCostCalculatorTest.php +++ b/server/tests/Unit/AutoCostCalculatorTest.php @@ -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']); } -} \ No newline at end of file +}