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

@@ -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

View File

@@ -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.

View File

@@ -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
<Directory /app/web/mobile/public>
Options Indexes FollowSymLinks
AllowOverride None
Require all granted
</Directory>
EOF
ln -sf /etc/apache2/conf-available/mobile-alias.conf /etc/apache2/conf-enabled/mobile-alias.conf
apache2-foreground
volumes:
pgdata:

28
docs/wiki/auth.md Normal file
View File

@@ -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"]}`

18
docs/wiki/blueprints.md Normal file
View File

@@ -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"}}`

24
docs/wiki/build_queue.md Normal file
View File

@@ -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"}`

18
docs/wiki/economy.md Normal file
View File

@@ -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"}`

10
docs/wiki/index.md Normal file
View File

@@ -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.

24
docs/wiki/permissions.md Normal file
View File

@@ -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"}`

View File

@@ -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"}`

View File

@@ -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

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']);
}
}
}