chore: sync
This commit is contained in:
12
.env.example
12
.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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
28
docs/wiki/auth.md
Normal 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
18
docs/wiki/blueprints.md
Normal 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
24
docs/wiki/build_queue.md
Normal 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
18
docs/wiki/economy.md
Normal 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
10
docs/wiki/index.md
Normal 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
24
docs/wiki/permissions.md
Normal 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"}`
|
||||
18
docs/wiki/planet_generator.md
Normal file
18
docs/wiki/planet_generator.md
Normal 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"}`
|
||||
34
planning/rulebook_aufgaben.md
Normal file
34
planning/rulebook_aufgaben.md
Normal 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
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
131
server/src/Config/AppConfig.php
Normal file
131
server/src/Config/AppConfig.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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' => []];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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');
|
||||
|
||||
12
server/src/Module/Email/EmailSenderInterface.php
Normal file
12
server/src/Module/Email/EmailSenderInterface.php
Normal 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;
|
||||
}
|
||||
81
server/src/Module/Email/SmtpTransport.php
Normal file
81
server/src/Module/Email/SmtpTransport.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user