feat: CI/CD pipeline setup complete - Ansible playbooks updated, secrets configured, workflow ready

This commit is contained in:
2025-10-31 01:39:24 +01:00
parent 55c04e4fd0
commit e26eb2aa12
601 changed files with 44184 additions and 32477 deletions

View File

@@ -290,7 +290,7 @@ final class AnalyticsCollector
return true;
}
return $this->random->float() <= $this->samplingRate;
return $this->random->float(0, 1) <= $this->samplingRate;
}
/**

View File

@@ -8,6 +8,7 @@ use App\Framework\Analytics\Bridges\SecurityEventBridge;
use App\Framework\Analytics\Listeners\SecurityAnalyticsListener;
use App\Framework\Analytics\Storage\AnalyticsStorage;
use App\Framework\Analytics\Storage\PerformanceBasedAnalyticsStorage;
use App\Framework\Config\Environment;
use App\Framework\DI\Container;
use App\Framework\DI\Initializer;
use App\Framework\Filesystem\AtomicStorage;
@@ -25,12 +26,16 @@ use App\Framework\Random\SecureRandomGenerator;
*/
final readonly class AnalyticsInitializer
{
public function __construct(
private Environment $environment,
){}
#[Initializer]
public function initializeAnalytics(Container $container): void
{
// Analytics Config
$container->singleton(AnalyticsConfig::class, function () {
return AnalyticsConfig::fromEnvironment();
return AnalyticsConfig::fromEnvironment($this->environment);
});
// Core Analytics Storage

View File

@@ -7,6 +7,8 @@ namespace App\Framework\Cache;
use App\Framework\Cache\Compression\NoCompression;
use App\Framework\Cache\Contracts\DriverAccessible;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Logging\Logger;
use App\Framework\Logging\ValueObjects\LogContext;
use App\Framework\Serializer\Serializer;
final readonly class GeneralCache implements Cache, DriverAccessible
@@ -17,7 +19,8 @@ final readonly class GeneralCache implements Cache, DriverAccessible
private CacheDriver $adapter,
private Serializer $serializer,
private CompressionAlgorithm $compressionAlgorithm = new NoCompression(),
private bool $autoCompress = true
private bool $autoCompress = true,
private ?Logger $logger = null
) {
}
@@ -255,48 +258,62 @@ final readonly class GeneralCache implements Cache, DriverAccessible
}
/**
* EMERGENCY: Monitor cache item sizes to identify memory explosion sources
* Monitor cache item sizes to identify memory explosion sources
*/
private function monitorCacheSize(CacheItem $item): void
{
if ($this->logger === null) {
return; // No logging if logger not provided
}
try {
// Get serialized size to understand cache impact
$serializedValue = serialize($item->value);
$sizeBytes = strlen($serializedValue);
$sizeKB = round($sizeBytes / 1024, 2);
$sizeMB = round($sizeBytes / 1024 / 1024, 2);
// Log large cache items
if ($sizeBytes > 50 * 1024) { // >50KB for GeneralCache
$keyString = $item->key->toString();
$valueType = get_debug_type($item->value);
$keyString = (string) $item->key;
$pattern = $this->analyzeCacheKeyPattern($keyString);
error_log("⚠️ GENERAL CACHE SIZE WARNING: {$sizeMB}MB");
error_log(" 📋 Cache Key: '{$keyString}'");
error_log(" 🏷️ Value Type: {$valueType}");
// If it's an object, get class name
if (is_object($item->value)) {
$className = get_class($item->value);
error_log(" 🔍 Object Class: {$className}");
}
// Show cache key pattern analysis
$keyAnalysis = $this->analyzeCacheKeyPattern($keyString);
error_log(" 🔑 Key Pattern: {$keyAnalysis}");
// Monitor large cache items (>50KB for GeneralCache)
if ($sizeBytes > 50 * 1024) {
$this->logger->warning(
'Large cache item detected in GeneralCache',
LogContext::withData([
'key' => $keyString,
'size_kb' => $sizeKB,
'size_mb' => $sizeMB,
'pattern' => $pattern,
'threshold' => '50KB',
])
);
}
// EMERGENCY: Block extremely large cache items
if ($sizeBytes > 5 * 1024 * 1024) { // >5MB for GeneralCache (more strict)
error_log("🛑 GENERAL CACHE BLOCK: Refusing to cache {$sizeMB}MB item");
error_log(" 📋 Blocked Key: '{$keyString}'");
error_log(" 🚫 Reason: Exceeds 5MB limit");
$this->logger->error(
'GeneralCache BLOCK: Refusing to cache oversized item',
LogContext::withData([
'key' => $keyString,
'size_mb' => $sizeMB,
'limit_mb' => 5,
'pattern' => $pattern,
'action' => 'blocked',
])
);
throw new \RuntimeException("GeneralCache item too large: {$sizeMB}MB (max 5MB)");
}
} catch (\RuntimeException $e) {
// Re-throw RuntimeException for size limit violations
throw $e;
} catch (\Throwable $e) {
// Don't break caching, just log the monitoring error
error_log("GeneralCache size monitoring failed: " . $e->getMessage());
$this->logger?->error(
'GeneralCache size monitoring failed',
LogContext::withException($e)
);
}
}

View File

@@ -10,6 +10,8 @@ use App\Framework\Cache\Contracts\Scannable;
use App\Framework\Cache\Strategies\CacheStrategyManager;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Logging\Logger;
use App\Framework\Logging\ValueObjects\LogContext;
/**
* Smart cache implementation with composable strategy support
@@ -36,7 +38,8 @@ final class SmartCache implements Cache, DriverAccessible
private readonly ?AsyncService $asyncService = null,
private readonly bool $asyncEnabled = true,
?CacheStrategyManager $strategyManager = null,
private readonly bool $enableDefaultStrategies = true
private readonly bool $enableDefaultStrategies = true,
private readonly ?Logger $logger = null
) {
// Initialize strategy manager with defaults if enabled and none provided
if ($strategyManager === null && $this->enableDefaultStrategies) {
@@ -913,50 +916,62 @@ final class SmartCache implements Cache, DriverAccessible
}
/**
* EMERGENCY: Monitor cache item sizes to identify memory explosion sources
* Monitor cache item sizes to identify memory explosion sources
*/
private function monitorCacheItemSize(CacheItem $item): void
{
if ($this->logger === null) {
return; // No logging if logger not provided
}
try {
// Get serialized size to understand cache impact
$serializedValue = serialize($item->value);
$sizeBytes = strlen($serializedValue);
$sizeKB = round($sizeBytes / 1024, 2);
$sizeMB = round($sizeBytes / 1024 / 1024, 2);
// Log large cache items
if ($sizeBytes > 100 * 1024) { // >100KB
$keyString = $item->key->toString();
$valueType = get_debug_type($item->value);
$keyString = (string) $item->key;
$pattern = $this->analyzeCacheKey($keyString);
error_log("🚨 SMART CACHE SIZE WARNING: {$sizeMB}MB");
error_log(" 📋 Cache Key: '{$keyString}'");
error_log(" 🏷️ Value Type: {$valueType}");
// If it's an object, get class name and some properties
if (is_object($item->value)) {
$className = get_class($item->value);
$objectInfo = $this->analyzeObject($item->value);
error_log(" 🔍 Object Class: {$className}");
error_log(" 📊 Object Analysis: {$objectInfo}");
}
// Show cache key pattern analysis
$keyAnalysis = $this->analyzeCacheKey($keyString);
error_log(" 🔑 Key Pattern: {$keyAnalysis}");
// Monitor large cache items (>100KB for SmartCache)
if ($sizeBytes > 100 * 1024) {
$this->logger->warning(
'Large cache item detected in SmartCache',
LogContext::withData([
'key' => $keyString,
'size_kb' => $sizeKB,
'size_mb' => $sizeMB,
'pattern' => $pattern,
'threshold' => '100KB',
])
);
}
// EMERGENCY: Block extremely large cache items
if ($sizeBytes > 10 * 1024 * 1024) { // >10MB
error_log("🛑 SMART CACHE BLOCK: Refusing to cache {$sizeMB}MB item");
error_log(" 📋 Blocked Key: '{$keyString}'");
error_log(" 🚫 Reason: Exceeds 10MB limit");
if ($sizeBytes > 10 * 1024 * 1024) { // >10MB for SmartCache (more lenient)
$this->logger->error(
'SmartCache BLOCK: Refusing to cache oversized item',
LogContext::withData([
'key' => $keyString,
'size_mb' => $sizeMB,
'limit_mb' => 10,
'pattern' => $pattern,
'action' => 'blocked',
])
);
throw new \RuntimeException("Cache item too large: {$sizeMB}MB (max 10MB)");
throw new \RuntimeException("SmartCache item too large: {$sizeMB}MB (max 10MB)");
}
} catch (\RuntimeException $e) {
// Re-throw RuntimeException for size limit violations
throw $e;
} catch (\Throwable $e) {
// Don't break caching, just log the monitoring error
error_log("Cache size monitoring failed: " . $e->getMessage());
$this->logger?->error(
'SmartCache size monitoring failed',
LogContext::withException($e)
);
}
}

View File

@@ -2,6 +2,8 @@
namespace App\Framework\Cache\Strategies;
use App\Framework\Core\ValueObjects\Timestamp;
/**
* Access pattern tracking for adaptive TTL calculation
*/

View File

@@ -203,100 +203,3 @@ final class AdaptiveTtlCacheStrategy implements CacheStrategy
return Duration::fromSeconds($boundedSeconds);
}
}
/**
* Access pattern tracking for adaptive TTL calculation
*/
final class AccessPattern
{
/** @var array<int, Timestamp> */
private array $accessTimes = [];
public function __construct(
private readonly int $windowSize
) {
}
public function recordAccess(): void
{
$this->accessTimes[] = Timestamp::now();
// Keep only recent accesses within the window
if (count($this->accessTimes) > $this->windowSize) {
array_shift($this->accessTimes);
}
}
public function getRecentAccessCount(): int
{
return count($this->accessTimes);
}
public function getTotalAccesses(): int
{
return count($this->accessTimes);
}
/**
* Calculate access frequency (accesses per hour)
*/
public function getAccessFrequency(): float
{
if (count($this->accessTimes) < 2) {
return 0.0;
}
$oldest = $this->accessTimes[0];
$newest = end($this->accessTimes);
$timeSpan = $newest->diff($oldest);
if ($timeSpan->toSeconds() <= 0) {
return 0.0;
}
$hours = $timeSpan->toSeconds() / 3600.0;
return count($this->accessTimes) / $hours;
}
}
/**
* Statistics for adaptive TTL optimization
*/
final class AdaptiveTtlStats
{
private int $hits = 0;
private int $misses = 0;
public function recordHitMiss(bool $isHit): void
{
if ($isHit) {
$this->hits++;
} else {
$this->misses++;
}
}
public function getHitRate(): float
{
$total = $this->hits + $this->misses;
return $total > 0 ? ($this->hits / $total) : 0.0;
}
public function getTotalRequests(): int
{
return $this->hits + $this->misses;
}
public function getHits(): int
{
return $this->hits;
}
public function getMisses(): int
{
return $this->misses;
}
}

View File

@@ -335,105 +335,3 @@ final class HeatMapCacheStrategy implements CacheStrategy
return 'Monitor and analyze access patterns';
}
}
/**
* Heat map entry for tracking cache key usage
*/
final class HeatMapEntry
{
/** @var array<array{timestamp: Timestamp, is_hit: bool, retrieval_time: ?float}> */
private array $accesses = [];
private int $totalHits = 0;
private int $totalMisses = 0;
private float $totalRetrievalTime = 0.0;
private int $retrievalTimeCount = 0;
public function __construct(
private readonly CacheKey $key
) {
}
public function recordAccess(bool $isHit, ?Duration $retrievalTime = null): void
{
$this->accesses[] = [
'timestamp' => Timestamp::now(),
'is_hit' => $isHit,
'retrieval_time' => $retrievalTime?->toSeconds(),
];
if ($isHit) {
$this->totalHits++;
} else {
$this->totalMisses++;
}
if ($retrievalTime !== null) {
$this->totalRetrievalTime += $retrievalTime->toSeconds();
$this->retrievalTimeCount++;
}
// Keep only recent data to prevent memory bloat
$cutoff = Timestamp::now()->subtract(Duration::fromHours(48)); // Keep 48 hours
$this->accesses = array_filter(
$this->accesses,
fn ($access) => $access['timestamp']->isAfter($cutoff)
);
}
public function getRecentAccesses(Timestamp $since): array
{
return array_filter(
$this->accesses,
fn ($access) => $access['timestamp']->isAfter($since)
);
}
public function getHitRate(): float
{
$total = $this->totalHits + $this->totalMisses;
return $total > 0 ? ($this->totalHits / $total) : 0.0;
}
public function getAverageRetrievalTime(): float
{
return $this->retrievalTimeCount > 0 ? ($this->totalRetrievalTime / $this->retrievalTimeCount) : 0.0;
}
public function getTotalAccesses(): int
{
return count($this->accesses);
}
public function getLastAccessTime(): ?Timestamp
{
if (empty($this->accesses)) {
return null;
}
return end($this->accesses)['timestamp'];
}
public function getKey(): CacheKey
{
return $this->key;
}
}
/**
* Write operation tracking
*/
final readonly class WriteOperation
{
public function __construct(
public CacheKey $key,
public int $valueSize,
public Duration $writeTime,
public Timestamp $timestamp
) {
}
}

View File

@@ -2,6 +2,10 @@
namespace App\Framework\Cache\Strategies;
use App\Framework\Cache\CacheKey;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Timestamp;
/**
* Heat map entry for tracking cache key usage
*/

View File

@@ -2,6 +2,10 @@
namespace App\Framework\Cache\Strategies;
use App\Framework\Cache\CacheKey;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Timestamp;
/**
* Prediction pattern for a cache key
*/

View File

@@ -450,100 +450,3 @@ final class PredictiveCacheStrategy implements CacheStrategy
}
}
}
/**
* Prediction pattern for a cache key
*/
final class PredictionPattern
{
/** @var array<array{timestamp: Timestamp, context: array}> */
private array $accesses = [];
/** @var array<CacheKey> */
private array $dependencies = [];
private mixed $warmingCallback = null;
public function __construct(
private readonly CacheKey $key
) {
}
public function recordAccess(Timestamp $timestamp, array $context = []): void
{
$this->accesses[] = [
'timestamp' => $timestamp,
'context' => $context,
];
// Keep only recent accesses to prevent memory bloat
$cutoff = $timestamp->subtract(Duration::fromHours(168)); // 1 week
$this->accesses = array_filter(
$this->accesses,
fn ($access) => $access['timestamp']->isAfter($cutoff)
);
}
public function addDependency(CacheKey $dependentKey): void
{
$this->dependencies[] = $dependentKey;
}
public function setWarmingCallback(callable $callback): void
{
$this->warmingCallback = $callback;
}
public function getKey(): CacheKey
{
return $this->key;
}
public function getRecentAccesses(int $hours): array
{
$cutoff = Timestamp::now()->subtract(Duration::fromHours($hours));
return array_filter(
$this->accesses,
fn ($access) => $access['timestamp']->isAfter($cutoff)
);
}
public function getDependencies(): array
{
return $this->dependencies;
}
public function getWarmingCallback(): ?callable
{
return $this->warmingCallback;
}
}
/**
* Active warming job
*/
final readonly class WarmingJob
{
public function __construct(
public CacheKey $key,
public mixed $callback,
public string $reason,
public Timestamp $startTime
) {
}
}
/**
* Warming operation result
*/
final readonly class WarmingResult
{
public function __construct(
public CacheKey $key,
public bool $successful,
public Duration $duration,
public string $reason
) {
}
}

View File

@@ -2,6 +2,9 @@
namespace App\Framework\Cache\Strategies;
use App\Framework\Cache\CacheKey;
use App\Framework\Core\ValueObjects\Timestamp;
/**
* Active warming job
*/

View File

@@ -2,6 +2,9 @@
namespace App\Framework\Cache\Strategies;
use App\Framework\Cache\CacheKey;
use App\Framework\Core\ValueObjects\Duration;
/**
* Warming operation result
*/

View File

@@ -2,6 +2,10 @@
namespace App\Framework\Cache\Strategies;
use App\Framework\Cache\CacheKey;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Timestamp;
/**
* Write operation tracking
*/

View File

@@ -50,8 +50,8 @@ final readonly class CriticalPathWarmingStrategy extends BaseWarmupStrategy
'ttl' => Duration::fromDays(7)
],
[
'key' => 'routes_dynamic',
'loader' => fn() => $this->compiledRoutes->getDynamicRoutes(),
'key' => 'routes_stats',
'loader' => fn() => $this->compiledRoutes->getStats(),
'ttl' => Duration::fromDays(7)
],
[
@@ -77,7 +77,7 @@ final readonly class CriticalPathWarmingStrategy extends BaseWarmupStrategy
$data = $item['loader']();
$ttl = $item['ttl'];
$cacheItem = CacheItem::forSetting(
$cacheItem = CacheItem::forSet(
key: $key,
value: $data,
ttl: $ttl
@@ -110,9 +110,10 @@ final readonly class CriticalPathWarmingStrategy extends BaseWarmupStrategy
protected function getMetadata(): array
{
$stats = $this->compiledRoutes->getStats();
return [
'routes_count' => count($this->compiledRoutes->getStaticRoutes()) +
count($this->compiledRoutes->getDynamicRoutes()),
'routes_count' => $stats['static_routes'] + $stats['dynamic_patterns'],
'config_keys' => 5,
'env_vars' => 4
];

View File

@@ -92,7 +92,7 @@ final readonly class PredictiveWarmingStrategy extends BaseWarmupStrategy
$key = CacheKey::fromString(self::ACCESS_PATTERN_CACHE_KEY);
$result = $this->cache->get($key);
if ($result->isHit()) {
if ($result->isHit) { // isHit is a property, not a method
return $result->value;
}

View File

@@ -50,7 +50,7 @@ final readonly class WarmupMetrics
{
$total = $this->totalItemsWarmed + $this->totalItemsFailed;
if ($total === 0) {
return 1.0;
return 0.0;
}
return $this->totalItemsWarmed / $total;
}
@@ -75,7 +75,8 @@ final readonly class WarmupMetrics
'total_strategies_executed' => $this->totalStrategiesExecuted,
'total_items_warmed' => $this->totalItemsWarmed,
'total_items_failed' => $this->totalItemsFailed,
'total_duration_seconds' => round($this->totalDurationSeconds, 3),
'total_duration_seconds' => $this->totalDurationSeconds,
'total_memory_used_bytes' => $this->totalMemoryUsedBytes,
'total_memory_used_mb' => round($this->getTotalMemoryUsedMB(), 2),
'overall_success_rate' => round($this->getOverallSuccessRate() * 100, 2),
'average_items_per_second' => round($this->getAverageItemsPerSecond(), 2),

View File

@@ -41,7 +41,7 @@ final readonly class WarmupResult
{
$total = $this->itemsWarmed + $this->itemsFailed;
if ($total === 0) {
return 1.0;
return 0.0;
}
return $this->itemsWarmed / $total;
}
@@ -65,7 +65,8 @@ final readonly class WarmupResult
'strategy_name' => $this->strategyName,
'items_warmed' => $this->itemsWarmed,
'items_failed' => $this->itemsFailed,
'duration_seconds' => round($this->durationSeconds, 3),
'duration_seconds' => $this->durationSeconds,
'memory_used_bytes' => $this->memoryUsedBytes,
'memory_used_mb' => round($this->getMemoryUsedMB(), 2),
'success_rate' => round($this->getSuccessRate() * 100, 2),
'items_per_second' => round($this->getItemsPerSecond(), 2),

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Framework\Config;
use App\Framework\Filesystem\ValueObjects\FilePath;
/**
* Resolves Docker Secrets using the *_FILE pattern
*
* Docker Secrets Workflow:
* 1. Check if {KEY}_FILE environment variable exists
* 2. Read secret value from file path
* 3. Return trimmed content
*
* Example:
* - DB_PASSWORD_FILE=/run/secrets/db_password
* - Resolves to content of that file
*/
final readonly class DockerSecretsResolver
{
/**
* Resolve a Docker Secret for given key
*
* @param string $key Environment variable name (e.g., 'DB_PASSWORD')
* @param array<string, mixed> $variables All environment variables
* @return string|null Secret value from file, or null if not found
*/
public function resolve(string $key, array $variables): ?string
{
$fileKey = $key . '_FILE';
// Check if *_FILE variable exists
if (!isset($variables[$fileKey])) {
return null;
}
$filePath = $variables[$fileKey];
// Validate file path
if (!is_string($filePath)) {
return null;
}
try {
$file = FilePath::create($filePath);
if (!$file->exists() || !$file->isReadable()) {
return null;
}
$content = file_get_contents($file->toString());
if ($content === false) {
return null;
}
return trim($content);
} catch (\Throwable) {
// Invalid file path or read error
return null;
}
}
/**
* Check if a Docker Secret exists for given key
*/
public function hasSecret(string $key, array $variables): bool
{
return $this->resolve($key, $variables) !== null;
}
/**
* Resolve multiple secrets at once
*
* @param array<string> $keys
* @param array<string, mixed> $variables
* @return array<string, string> Map of key => resolved value (only successful resolutions)
*/
public function resolveMultiple(array $keys, array $variables): array
{
$resolved = [];
foreach ($keys as $key) {
$value = $this->resolve($key, $variables);
if ($value !== null) {
$resolved[$key] = $value;
}
}
return $resolved;
}
}

View File

@@ -6,7 +6,6 @@ namespace App\Framework\Config;
use App\Framework\Encryption\EncryptionFactory;
use App\Framework\Filesystem\ValueObjects\FilePath;
use App\Framework\Random\RandomGenerator;
/**
* Enhanced environment loader with encryption support
@@ -14,124 +13,161 @@ use App\Framework\Random\RandomGenerator;
final readonly class EncryptedEnvLoader
{
public function __construct(
private EncryptionFactory $encryptionFactory,
private RandomGenerator $randomGenerator
) {
private EncryptionFactory $encryptionFactory = new EncryptionFactory,
private EnvFileParser $parser = new EnvFileParser()
) {}
/**
* Load environment with automatic encryption support detection
*
* This method:
* 1. Loads Docker ENV vars and .env files
* 2. Checks for ENCRYPTION_KEY in loaded environment
* 3. If found, reloads with encryption support for .env.secrets
*
* @param FilePath|string $basePath Base path to project root
* @return Environment Loaded environment with all variables
*/
public function load(FilePath|string $basePath): Environment
{
// First pass: Load environment (Docker ENV + .env files)
$env = $this->loadEnvironment($basePath);
// Check if we have encryption key for secrets
$encryptionKey = $env->get('ENCRYPTION_KEY');
if ($encryptionKey === null) {
// No encryption key, return environment as-is
return $env;
}
try {
// Second pass: Reload with encryption support for .env.secrets
return $this->loadEnvironment($basePath, $encryptionKey);
} catch (\Throwable $e) {
// Fallback to environment without secrets if encryption fails
error_log("Failed to load encrypted secrets: " . $e->getMessage());
error_log("Continuing with non-encrypted environment variables.");
return $env;
}
}
/**
* Load environment from multiple files with encryption support
*
* Priority Strategy:
* - Production: Docker ENV vars take precedence over .env files
* - Development: .env files can override system environment
*/
public function loadEnvironment(FilePath|string $basePath, ?string $encryptionKey = null): Environment
{
$variables = [];
// Start with system environment variables (Docker/OS)
$systemVariables = $this->loadSystemEnvironment();
// Determine environment type from system variables first
$appEnv = $systemVariables['APP_ENV'] ?? 'development';
$isProduction = $appEnv === 'production';
$baseDir = $basePath instanceof FilePath ? $basePath : FilePath::create($basePath);
// Load base .env file
$envFile = $baseDir->join('.env');
if ($envFile->exists()) {
$variables = array_merge($variables, $this->parseEnvFile($envFile));
// For PRODUCTION: Docker ENV vars have highest priority
// For DEVELOPMENT: .env files can override system env
if ($isProduction) {
// Production: System ENV → .env files (only if not set)
$variables = $systemVariables;
// Load .env files but DON'T override Docker ENV vars
$envFile = $baseDir->join('.env');
if ($envFile->exists()) {
$fileVariables = $this->parser->parse($envFile);
// Only add variables that aren't already set by Docker
foreach ($fileVariables as $key => $value) {
if (!isset($variables[$key])) {
$variables[$key] = $value;
}
}
}
} else {
// Development: .env files → System ENV (local development workflow)
$variables = $systemVariables;
// Load base .env file (can override system env for development)
$envFile = $baseDir->join('.env');
if ($envFile->exists()) {
$variables = array_merge($variables, $this->parser->parse($envFile));
}
}
// Load .env.secrets file if it exists and encryption key is provided
$secretsFile = $baseDir->join('.env.secrets');
if ($secretsFile->exists() && $encryptionKey !== null) {
$secretsVariables = $this->parseEnvFile($secretsFile);
$secretsVariables = $this->parser->parse($secretsFile);
$variables = array_merge($variables, $secretsVariables);
}
// Load environment-specific files
// Priority: 1. Already loaded from .env file, 2. Default to development
// Re-check APP_ENV after .env loading
$appEnv = $variables['APP_ENV'] ?? 'development';
// Load environment-specific files
$envSpecificFile = $baseDir->join(".env.{$appEnv}");
if ($envSpecificFile->exists()) {
$envSpecificVariables = $this->parseEnvFile($envSpecificFile);
$variables = array_merge($variables, $envSpecificVariables);
if ($isProduction) {
// Production: Only add missing variables
$envSpecificVariables = $this->parser->parse($envSpecificFile);
foreach ($envSpecificVariables as $key => $value) {
if (!isset($variables[$key])) {
$variables[$key] = $value;
}
}
} else {
// Development: Allow override
$variables = array_merge($variables, $this->parser->parse($envSpecificFile));
}
}
return new Environment($variables);
}
/**
* Parse a single .env file
* Load variables from system environment (getenv(), $_ENV, $_SERVER)
*
* Priority order optimized for PHP-FPM compatibility:
* 1. getenv() - Works in PHP-FPM, reads from actual process environment
* 2. $_ENV - May be empty in PHP-FPM
* 3. $_SERVER - May contain additional vars from web server
*/
private function parseEnvFile(FilePath|string $filePath): array
private function loadSystemEnvironment(): array
{
$variables = [];
$file = $filePath instanceof FilePath ? $filePath : FilePath::create($filePath);
if (! $file->isReadable()) {
return $variables;
// 1. Load from getenv() - THIS WORKS IN PHP-FPM!
// In PHP-FPM, $_ENV and $_SERVER are empty by default.
// getenv() reads from actual process environment, unlike $_ENV
$allEnvVars = getenv();
if ($allEnvVars !== false) {
foreach ($allEnvVars as $key => $value) {
$variables[$key] = $value;
}
}
$lines = file($file->toString(), FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if ($lines === false) {
return $variables;
// 2. Load from $_ENV (may be empty in PHP-FPM)
foreach ($_ENV as $key => $value) {
if (!isset($variables[$key])) {
$variables[$key] = $value;
}
}
foreach ($lines as $lineNumber => $line) {
$line = trim($line);
// Skip comments and empty lines
if (str_starts_with($line, '#') || ! str_contains($line, '=')) {
continue;
// 3. Load from $_SERVER (may contain additional vars from web server)
foreach ($_SERVER as $key => $value) {
if (!isset($variables[$key]) && is_string($value)) {
$variables[$key] = $value;
}
// Parse key=value pairs
$equalPos = strpos($line, '=');
if ($equalPos === false) {
continue;
}
$name = trim(substr($line, 0, $equalPos));
$value = trim(substr($line, $equalPos + 1));
// Remove surrounding quotes
$value = $this->removeQuotes($value);
// Handle multiline values (basic support)
if (str_ends_with($value, '\\')) {
$value = rtrim($value, '\\');
// In a full implementation, you'd continue reading the next line
}
$variables[$name] = $this->castValue($value);
}
return $variables;
}
/**
* Remove surrounding quotes from values
*/
private function removeQuotes(string $value): string
{
// Remove double quotes
if (str_starts_with($value, '"') && str_ends_with($value, '"') && strlen($value) > 1) {
return substr($value, 1, -1);
}
// Remove single quotes
if (str_starts_with($value, "'") && str_ends_with($value, "'") && strlen($value) > 1) {
return substr($value, 1, -1);
}
return $value;
}
/**
* Cast string values to appropriate types
*/
private function castValue(string $value): mixed
{
return match (strtolower($value)) {
'true' => true,
'false' => false,
'null' => null,
default => is_numeric($value) ? (str_contains($value, '.') ? (float) $value : (int) $value) : $value
};
}
/**
* Generate a .env.secrets template file
*/
@@ -218,7 +254,7 @@ final readonly class EncryptedEnvLoader
$shouldEncrypt = empty($keysToEncrypt) ? str_starts_with($name, 'SECRET_') : in_array($name, $keysToEncrypt, true);
if ($shouldEncrypt && ! $encryption->isEncrypted($value)) {
$value = $this->removeQuotes($value);
$value = $this->parser->removeQuotes($value);
$encryptedValue = $encryption->encrypt($value);
$newLines[] = "{$name}={$encryptedValue}";
$encryptedCount++;

View File

@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace App\Framework\Config;
use App\Framework\Filesystem\ValueObjects\FilePath;
/**
* Unified .env file parser
*
* Handles:
* - Comment lines (#)
* - Empty lines
* - Key=Value parsing
* - Quote removal (single and double)
* - Type casting (bool, null, int, float, string)
*/
final readonly class EnvFileParser
{
/**
* Parse .env file into associative array
*
* @return array<string, mixed>
*/
public function parse(FilePath|string $filePath): array
{
$file = $filePath instanceof FilePath ? $filePath : FilePath::create($filePath);
if (!$file->exists() || !$file->isReadable()) {
return [];
}
$lines = file($file->toString(), FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if ($lines === false) {
return [];
}
return $this->parseLines($lines);
}
/**
* Parse lines from .env file
*
* @param array<string> $lines
* @return array<string, mixed>
*/
public function parseLines(array $lines): array
{
$variables = [];
foreach ($lines as $line) {
$trimmed = trim($line);
// Skip comments and lines without =
if ($this->shouldSkipLine($trimmed)) {
continue;
}
$parsed = $this->parseLine($trimmed);
if ($parsed !== null) {
[$key, $value] = $parsed;
$variables[$key] = $value;
}
}
return $variables;
}
/**
* Check if line should be skipped
*/
private function shouldSkipLine(string $line): bool
{
return str_starts_with($line, '#') || !str_contains($line, '=');
}
/**
* Parse a single key=value line
*
* @return array{0: string, 1: mixed}|null [key, value] tuple or null if invalid
*/
private function parseLine(string $line): ?array
{
$equalPos = strpos($line, '=');
if ($equalPos === false) {
return null;
}
$key = trim(substr($line, 0, $equalPos));
$value = trim(substr($line, $equalPos + 1));
// Remove surrounding quotes
$value = $this->removeQuotes($value);
// Handle multiline values (basic support)
if (str_ends_with($value, '\\')) {
$value = rtrim($value, '\\');
// Note: Full multiline support would require reading next line
}
return [$key, $this->castValue($value)];
}
/**
* Remove surrounding quotes from value
*/
public function removeQuotes(string $value): string
{
// Remove double quotes
if (str_starts_with($value, '"') && str_ends_with($value, '"') && strlen($value) > 1) {
return substr($value, 1, -1);
}
// Remove single quotes
if (str_starts_with($value, "'") && str_ends_with($value, "'") && strlen($value) > 1) {
return substr($value, 1, -1);
}
return $value;
}
/**
* Cast string values to appropriate PHP types
*/
private function castValue(string $value): mixed
{
return match (strtolower($value)) {
'true' => true,
'false' => false,
'null' => null,
default => $this->castNumericValue($value)
};
}
/**
* Cast numeric values or return as string
*/
private function castNumericValue(string $value): int|float|string
{
if (!is_numeric($value)) {
return $value;
}
return str_contains($value, '.') ? (float) $value : (int) $value;
}
/**
* Parse string content directly (for testing)
*
* @return array<string, mixed>
*/
public function parseString(string $content): array
{
$lines = explode("\n", $content);
return $this->parseLines($lines);
}
}

View File

@@ -16,6 +16,7 @@ enum EnvKey: string
// Feature Flags
case ENABLE_CONTEXT_AWARE_INITIALIZERS = 'ENABLE_CONTEXT_AWARE_INITIALIZERS';
case MCP_SERVER_MODE = 'MCP_SERVER_MODE';
// Database
case DB_DRIVER = 'DB_DRIVER';
@@ -59,4 +60,18 @@ enum EnvKey: string
// Vault Configuration
case VAULT_ENCRYPTION_KEY = 'VAULT_ENCRYPTION_KEY';
// Error Reporting Configuration
case ERROR_REPORTING_ENABLED = 'ERROR_REPORTING_ENABLED';
case ERROR_REPORTING_ASYNC = 'ERROR_REPORTING_ASYNC';
case ERROR_REPORTING_FILTER_LEVELS = 'ERROR_REPORTING_FILTER_LEVELS';
case ERROR_REPORTING_EXCLUDED_TYPES = 'ERROR_REPORTING_EXCLUDED_TYPES';
case ERROR_REPORTING_CAPTURE_REQUEST = 'ERROR_REPORTING_CAPTURE_REQUEST';
case ERROR_REPORTING_CAPTURE_USER = 'ERROR_REPORTING_CAPTURE_USER';
case ERROR_REPORTING_CAPTURE_STACK_TRACES = 'ERROR_REPORTING_CAPTURE_STACK_TRACES';
case ERROR_REPORTING_MAX_STACK_DEPTH = 'ERROR_REPORTING_MAX_STACK_DEPTH';
case ERROR_REPORTING_SANITIZE = 'ERROR_REPORTING_SANITIZE';
case ERROR_REPORTING_SAMPLING_RATE = 'ERROR_REPORTING_SAMPLING_RATE';
case ERROR_REPORTING_MAX_PER_MINUTE = 'ERROR_REPORTING_MAX_PER_MINUTE';
case ERROR_REPORTING_ANALYTICS = 'ERROR_REPORTING_ANALYTICS';
case ERROR_REPORTING_RETENTION_DAYS = 'ERROR_REPORTING_RETENTION_DAYS';
}

View File

@@ -14,7 +14,8 @@ final readonly class Environment
* @param array<string, mixed> $variables
*/
public function __construct(
private array $variables = []
private array $variables = [],
private DockerSecretsResolver $secretsResolver = new DockerSecretsResolver()
) {
}
@@ -22,8 +23,19 @@ final readonly class Environment
{
$key = $this->keyToString($key);
// Priorität: 1. System ENV, 2. Loaded variables, 3. Default
return /*$_ENV[$key] ?? $_SERVER[$key] ?? getenv($key) ?:*/ $this->variables[$key] ?? $default;
// 1. Check if direct env var exists
if (isset($this->variables[$key])) {
return $this->variables[$key];
}
// 2. Docker Secrets support: Check for *_FILE pattern
$secretValue = $this->secretsResolver->resolve($key, $this->variables);
if ($secretValue !== null) {
return $secretValue;
}
// 3. Return default
return $default;
}
public function getRequired(EnvKey|string $key): mixed
@@ -74,7 +86,7 @@ final readonly class Environment
}
/** @param class-string<BackedEnum> $enumClass */
public function getEnum(EnvKey|string $key, string $enumClass, BackedEnum $default): object
public function getEnum(EnvKey|string $key, string $enumClass, BackedEnum $default): BackedEnum
{
$key = $this->keyToString($key);
@@ -82,9 +94,13 @@ final readonly class Environment
throw new \InvalidArgumentException('Default value must be an instance of the enum class');
}
return forward_static_call([$enumClass, 'tryFrom'], $key) ?? $default;
$value = $this->get($key);
#$enumClass::tryFrom($this->get($key, $default));
if ($value === null) {
return $default;
}
return $enumClass::tryFrom($value) ?? $default;
}
public function has(EnvKey|string $key): bool
@@ -100,8 +116,8 @@ final readonly class Environment
public function all(): array
{
return array_merge(
$_ENV,
$_SERVER,
#$_ENV,
#$_SERVER,
$this->variables
);
}
@@ -111,43 +127,12 @@ final readonly class Environment
*/
public static function fromFile(FilePath|string $envPath): self
{
$variables = [];
$filePath = $envPath instanceof FilePath ? $envPath : FilePath::create($envPath);
if ($filePath->exists() && $filePath->isReadable()) {
$lines = file($filePath->toString(), FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
if (str_starts_with(trim($line), '#') || ! str_contains($line, '=')) {
continue;
}
[$name, $value] = explode('=', $line, 2);
$name = trim($name);
$value = trim($value);
// Remove quotes
if (str_starts_with($value, '"') && str_ends_with($value, '"')) {
$value = substr($value, 1, -1);
}
$variables[$name] = self::castValue($value);
}
}
$parser = new EnvFileParser();
$variables = $parser->parse($envPath);
return new self($variables);
}
private static function castValue(string $value): mixed
{
return match (strtolower($value)) {
'true' => true,
'false' => false,
'null' => null,
default => is_numeric($value) ? (str_contains($value, '.') ? (float) $value : (int) $value) : $value
};
}
public function keyToString(EnvKey|string $key): string
{
if (is_string($key)) {

View File

@@ -10,6 +10,7 @@ use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\ExitCode;
use App\Framework\Console\Screen\CursorControlCode;
use App\Framework\Console\Screen\MouseControlCode;
use App\Framework\Console\Screen\ScreenControlCode;
use App\Framework\Console\SimpleWorkflowExecutor;
use App\Framework\Console\TuiView;
@@ -22,6 +23,8 @@ use App\Framework\Discovery\Results\DiscoveryRegistry;
*/
final readonly class ConsoleTUI
{
private InputParser $inputParser;
public function __construct(
private ConsoleOutputInterface $output,
private Container $container,
@@ -34,6 +37,7 @@ final readonly class ConsoleTUI
private CommandGroupRegistry $groupRegistry,
private SimpleWorkflowExecutor $workflowExecutor
) {
$this->inputParser = new InputParser();
}
/**
@@ -64,12 +68,59 @@ final readonly class ConsoleTUI
*/
private function mainLoop(): void
{
$needsRender = true;
while ($this->state->isRunning()) {
$this->renderCurrentView();
$this->handleUserInput();
// Only render when needed
if ($needsRender) {
$this->renderCurrentView();
$needsRender = false;
}
// Handle input (non-blocking)
$event = $this->inputParser->readEvent();
if ($event === null) {
// No input available, sleep briefly to reduce CPU usage
usleep(50000); // 50ms
continue;
}
// Process event and mark for re-render
$needsRender = $this->processEvent($event);
}
}
/**
* Process input event and return whether re-render is needed
*/
private function processEvent(MouseEvent|KeyEvent $event): bool
{
// Handle form mode specially
if ($this->state->getCurrentView() === TuiView::FORM) {
$this->handleFormMode();
return true;
}
// Dispatch event to input handler
if ($event instanceof MouseEvent) {
$this->inputHandler->handleMouseEvent($event, $this->state);
return true; // Mouse events always need re-render
} elseif ($event instanceof KeyEvent) {
// Handle Ctrl+C
if ($event->ctrl && $event->key === 'C') {
$this->state->setRunning(false);
return false;
}
// Use new KeyEvent handler (supports Alt+Letter shortcuts)
$this->inputHandler->handleKeyEvent($event, $this->state, $this->commandHistory);
return true; // Key events always need re-render
}
return false;
}
/**
* Cleanup and restore terminal
*/
@@ -89,6 +140,13 @@ final readonly class ConsoleTUI
$this->setRawMode(true);
$this->output->write(CursorControlCode::HIDE->format());
// Enable mouse reporting (SGR format)
$this->output->write(MouseControlCode::ENABLE_ALL->format());
$this->output->write(MouseControlCode::ENABLE_SGR->format());
// Optional: Enable alternate screen buffer
$this->output->write(ScreenControlCode::ALTERNATE_BUFFER->format());
// Welcome message
$this->output->write(ScreenControlCode::CLEAR_ALL->format());
$this->output->write(CursorControlCode::POSITION->format(1, 1));
@@ -99,8 +157,24 @@ final readonly class ConsoleTUI
*/
private function restoreTerminal(): void
{
$this->setRawMode(false);
// Disable mouse reporting
$this->output->write(MouseControlCode::DISABLE_SGR->format());
$this->output->write(MouseControlCode::DISABLE_ALL->format());
// Show cursor
$this->output->write(CursorControlCode::SHOW->format());
// Deactivate alternate screen buffer
$this->output->write(ScreenControlCode::MAIN_BUFFER->format());
// Restore terminal mode
$this->setRawMode(false);
// Reset terminal (stty sane)
if (function_exists('shell_exec')) {
shell_exec('stty sane 2>/dev/null');
}
$this->output->screen->setInteractiveMode(false);
}
@@ -126,24 +200,7 @@ final readonly class ConsoleTUI
$this->renderer->render($this->state, $this->commandHistory);
}
/**
* Handle user input
*/
private function handleUserInput(): void
{
$key = $this->readKey();
if ($key !== '') {
// Handle form mode specially
if ($this->state->getCurrentView() === TuiView::FORM) {
$this->handleFormMode();
return;
}
$this->inputHandler->handleInput($key, $this->state, $this->commandHistory);
}
}
/**
* Handle form mode interaction
@@ -243,7 +300,13 @@ final readonly class ConsoleTUI
$this->output->writeLine('Press any key to continue...', ConsoleColor::YELLOW);
// Wait for user input
$this->readKey();
while (true) {
$event = $this->inputParser->readEvent();
if ($event !== null) {
break;
}
usleep(10000); // 10ms sleep
}
}
/**

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Components;
/**
* Dispatches events to UI components
*/
final class EventDispatcher
{
/** @var array<callable(MouseEvent, TuiState): void> */
private array $mouseListeners = [];
/** @var array<callable(KeyEvent, TuiState): void> */
private array $keyListeners = [];
/**
* Register a mouse event listener
*/
public function onMouseEvent(callable $listener): void
{
$this->mouseListeners[] = $listener;
}
/**
* Register a keyboard event listener
*/
public function onKeyEvent(callable $listener): void
{
$this->keyListeners[] = $listener;
}
/**
* Dispatch a mouse event
*/
public function dispatchMouseEvent(MouseEvent $event, TuiState $state): void
{
foreach ($this->mouseListeners as $listener) {
$listener($event, $state);
}
}
/**
* Dispatch a keyboard event
*/
public function dispatchKeyEvent(KeyEvent $event, TuiState $state): void
{
foreach ($this->keyListeners as $listener) {
$listener($event, $state);
}
}
/**
* Clear all listeners
*/
public function clear(): void
{
$this->mouseListeners = [];
$this->keyListeners = [];
}
}

View File

@@ -0,0 +1,242 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Components;
/**
* Parses ANSI escape sequences for mouse and keyboard events
*/
final class InputParser
{
/**
* Read and parse input from STDIN using stream_select()
*
* @return MouseEvent|KeyEvent|null Returns parsed event or null if no input available
*/
public function readEvent(): MouseEvent|KeyEvent|null
{
// Use stream_select for non-blocking I/O
$read = [STDIN];
$write = null;
$except = null;
// Wait up to 0.01 seconds (10ms) for input
$result = stream_select($read, $write, $except, 0, 10000);
if ($result === false || $result === 0 || !in_array(STDIN, $read, true)) {
return null;
}
$originalBlocking = stream_get_meta_data(STDIN)['blocked'] ?? true;
stream_set_blocking(STDIN, false);
try {
$firstChar = fgetc(STDIN);
if ($firstChar === false) {
return null;
}
// Check for escape sequence
if ($firstChar === "\033") {
return $this->parseEscapeSequence($firstChar);
}
// Check for Ctrl+C (ASCII 3)
if ($firstChar === "\003") {
stream_set_blocking(STDIN, $originalBlocking);
return new KeyEvent(key: 'C', ctrl: true);
}
// Regular character
stream_set_blocking(STDIN, $originalBlocking);
return new KeyEvent(key: $firstChar);
} finally {
stream_set_blocking(STDIN, $originalBlocking);
}
}
/**
* Parse escape sequence (mouse or keyboard)
*/
private function parseEscapeSequence(string $firstChar): MouseEvent|KeyEvent|null
{
$sequence = $firstChar;
// Read next character with timeout
$next = $this->readCharWithTimeout();
if ($next === null) {
return new KeyEvent(key: "\033");
}
$sequence .= $next;
// Mouse events start with \e[<
if ($next === '[') {
$third = $this->readCharWithTimeout();
if ($third === '<') {
return $this->parseMouseEvent($sequence . $third);
}
// Keyboard escape sequence like \e[A (arrow up)
if ($third !== null) {
$sequence .= $third;
return $this->parseKeyboardSequence($sequence);
}
}
// Just escape key
return new KeyEvent(key: "\033");
}
/**
* Parse SGR mouse event: \e[<b;x;yM (press) or \e[<b;x;ym (release)
*/
private function parseMouseEvent(string $prefix): MouseEvent|null
{
$buffer = '';
$timeout = 10000; // 10ms
$startTime = microtime(true) * 1000;
// Read until we get 'M' or 'm'
while (true) {
$char = fgetc(STDIN);
if ($char === false) {
// Check timeout
$elapsed = (microtime(true) * 1000) - $startTime;
if ($elapsed > $timeout) {
return null;
}
usleep(1000);
continue;
}
$buffer .= $char;
// Mouse event ends with 'M' (press) or 'm' (release)
if ($char === 'M' || $char === 'm') {
break;
}
// Safety: limit buffer size
if (strlen($buffer) > 20) {
return null;
}
}
// Parse format: b;x;y where b is button code, x and y are coordinates
// Remove the final M/m
$data = substr($buffer, 0, -1);
$parts = explode(';', $data);
if (count($parts) < 3) {
return null;
}
$buttonCode = (int) $parts[0];
$x = (int) $parts[1];
$y = (int) $parts[2];
// Decode button and modifiers
// Bit flags in button code:
// Bit 0-1: Button (0=left, 1=middle, 2=right, 3=release)
// Bit 2: Shift
// Bit 3: Meta/Alt
// Bit 4: Ctrl
// Bit 5-6: Polarity (64=scroll up, 65=scroll down)
$button = $buttonCode & 0x03;
$shift = ($buttonCode & 0x04) !== 0;
$alt = ($buttonCode & 0x08) !== 0;
$ctrl = ($buttonCode & 0x10) !== 0;
// Handle scroll events (button codes 64 and 65)
if ($buttonCode >= 64 && $buttonCode <= 65) {
$button = $buttonCode;
}
$pressed = $buffer[-1] === 'M';
return new MouseEvent(
x: $x,
y: $y,
button: $button,
pressed: $pressed,
shift: $shift,
ctrl: $ctrl,
alt: $alt
);
}
/**
* Parse keyboard escape sequence (arrow keys, function keys, etc.)
*/
private function parseKeyboardSequence(string $sequence): KeyEvent
{
// Map common escape sequences
$keyMap = [
"\033[A" => 'ArrowUp',
"\033[B" => 'ArrowDown',
"\033[C" => 'ArrowRight',
"\033[D" => 'ArrowLeft',
"\033[H" => 'Home',
"\033[F" => 'End',
"\033[5~" => 'PageUp',
"\033[6~" => 'PageDown',
"\033[3~" => 'Delete',
"\n" => 'Enter',
"\r" => 'Enter',
"\033" => 'Escape',
"\t" => 'Tab',
];
// Check if we need to read more characters
if (strlen($sequence) >= 3 && in_array($sequence[2], ['5', '6', '3'], true)) {
$fourth = $this->readCharWithTimeout();
if ($fourth !== null) {
$sequence .= $fourth;
}
}
// Check for Enter key
if ($sequence === "\n" || $sequence === "\r") {
return new KeyEvent(key: 'Enter', code: "\n");
}
// Check for Escape
if ($sequence === "\033") {
return new KeyEvent(key: 'Escape', code: "\033");
}
// Map to known key
if (isset($keyMap[$sequence])) {
return new KeyEvent(key: $keyMap[$sequence], code: $sequence);
}
// Unknown sequence, return as-is
return new KeyEvent(key: $sequence, code: $sequence);
}
/**
* Read a single character with small timeout
*/
private function readCharWithTimeout(int $timeoutMs = 10): ?string
{
$startTime = microtime(true) * 1000;
while (true) {
$char = fgetc(STDIN);
if ($char !== false) {
return $char;
}
$elapsed = (microtime(true) * 1000) - $startTime;
if ($elapsed > $timeoutMs) {
return null;
}
usleep(1000); // 1ms
}
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Components;
/**
* Keyboard event value object
*/
final readonly class KeyEvent
{
public function __construct(
public string $key,
public string $code = '',
public bool $shift = false,
public bool $ctrl = false,
public bool $alt = false,
public bool $meta = false
) {
}
/**
* Get human-readable description
*/
public function getDescription(): string
{
$modifiers = [];
if ($this->ctrl) {
$modifiers[] = 'Ctrl';
}
if ($this->alt) {
$modifiers[] = 'Alt';
}
if ($this->shift) {
$modifiers[] = 'Shift';
}
if ($this->meta) {
$modifiers[] = 'Meta';
}
$modifierStr = ! empty($modifiers) ? implode('+', $modifiers) . '+' : '';
return $modifierStr . $this->key;
}
/**
* Check if this is a control character (like Ctrl+C)
*/
public function isControlChar(): bool
{
return $this->ctrl && strlen($this->key) === 1 && ord($this->key) < 32;
}
}

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Components;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleOutputInterface;
/**
* Menu bar with clickable entries
*/
final readonly class MenuBar
{
/**
* @param array<string> $items Menu items (e.g. ['Datei', 'Bearbeiten', 'Ansicht', 'Hilfe'])
*/
public function __construct(
private array $items
) {
}
/**
* Render the menu bar at the top of the screen
*/
public function render(ConsoleOutputInterface $output, ?string $activeMenu = null, int $screenWidth = 80): void
{
$menuLine = '';
$separator = ' | ';
foreach ($this->items as $index => $item) {
if ($index > 0) {
$menuLine .= $separator;
}
$isActive = $item === $activeMenu;
$color = $isActive ? ConsoleColor::BRIGHT_WHITE : ConsoleColor::WHITE;
$prefix = $isActive ? '▶ ' : ' ';
$menuLine .= "{$prefix}{$item}";
}
// Pad to screen width
$padding = max(0, $screenWidth - mb_strlen($menuLine));
$menuLine .= str_repeat(' ', $padding);
$output->writeLine($menuLine, ConsoleColor::BRIGHT_WHITE);
$output->writeLine(str_repeat('═', $screenWidth), ConsoleColor::GRAY);
}
/**
* Check if a mouse click is within the menu bar area
*/
public function isClickInMenuArea(int $x, int $y): bool
{
// Menu bar is always at y=1 (first line)
return $y === 1 && $x >= 1;
}
/**
* Get menu item at click position
*/
public function getItemAtPosition(int $x): ?string
{
// Simple calculation: approximate position based on item lengths
// This is a simplified version - in a real implementation, you'd track exact positions
$currentPos = 3; // Start after initial padding
foreach ($this->items as $item) {
$itemLength = mb_strlen($item) + 4; // Include prefix and spacing
if ($x >= $currentPos && $x < $currentPos + $itemLength) {
return $item;
}
$currentPos += $itemLength + 3; // Add separator length
}
return null;
}
/**
* Get hotkey for a menu item (first letter)
*/
public function getHotkeyForItem(string $item): ?string
{
$firstChar = mb_strtoupper(mb_substr($item, 0, 1));
if ($firstChar === '') {
return null;
}
return $firstChar;
}
/**
* Find menu item by hotkey
*/
public function getItemByHotkey(string $hotkey): ?string
{
$hotkeyUpper = mb_strtoupper($hotkey);
foreach ($this->items as $item) {
if (mb_strtoupper(mb_substr($item, 0, 1)) === $hotkeyUpper) {
return $item;
}
}
return null;
}
/**
* Get all menu items
*/
public function getItems(): array
{
return $this->items;
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Components;
/**
* Mouse event value object
*/
final readonly class MouseEvent
{
public function __construct(
public int $x,
public int $y,
public int $button,
public bool $pressed,
public bool $shift = false,
public bool $ctrl = false,
public bool $alt = false
) {
}
/**
* Get human-readable button name
*/
public function getButtonName(): string
{
return match ($this->button) {
0 => 'Left',
1 => 'Middle',
2 => 'Right',
64 => 'Scroll Up',
65 => 'Scroll Down',
default => 'Unknown',
};
}
/**
* Check if this is a scroll event
*/
public function isScrollEvent(): bool
{
return in_array($this->button, [64, 65], true);
}
}

View File

@@ -16,7 +16,8 @@ use App\Framework\Discovery\ValueObjects\DiscoveredAttribute;
final readonly class TuiInputHandler
{
public function __construct(
private TuiCommandExecutor $commandExecutor
private TuiCommandExecutor $commandExecutor,
private ?MenuBar $menuBar = null
) {
}
@@ -489,4 +490,86 @@ final readonly class TuiInputHandler
HistoryTab::FAVORITES => $history->getFavorites(),
};
}
/**
* Handle mouse events
*/
public function handleMouseEvent(MouseEvent $event, TuiState $state): void
{
// Only handle button presses, not releases
if (! $event->pressed) {
return;
}
// Only update status for menu clicks, not all mouse movements
// This reduces flickering
// Handle menu bar clicks
if ($this->menuBar !== null && $this->menuBar->isClickInMenuArea($event->x, $event->y)) {
$menuItem = $this->menuBar->getItemAtPosition($event->x);
if ($menuItem !== null && $event->button === 0) {
// Left click on menu item
$state->setActiveMenu($menuItem);
$state->setStatus("Menü geöffnet: {$menuItem}");
}
}
}
/**
* Handle keyboard events (for Alt+Letter menu shortcuts)
*/
public function handleKeyEvent(KeyEvent $event, TuiState $state, CommandHistory $history): void
{
// Handle Alt+Letter shortcuts for menu
if ($event->alt && $this->menuBar !== null && strlen($event->key) === 1) {
$menuItem = $this->menuBar->getItemByHotkey($event->key);
if ($menuItem !== null) {
$state->setActiveMenu($menuItem);
$state->setStatus("Menü geöffnet: {$menuItem}");
return;
}
}
// Convert to legacy key string format first
$keyString = $this->keyEventToString($event);
if ($keyString === '') {
return;
}
// Only update status for non-navigation keys to reduce flickering
$isNavigationKey = in_array($event->key, ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter', 'Escape', 'Tab'], true);
if (!$isNavigationKey) {
$keyDesc = $event->getDescription();
$state->setStatus("Taste: {$keyDesc}");
}
// Don't update status for navigation keys - let navigation happen without status flicker
// Process input - this handles navigation
$this->handleInput($keyString, $state, $history);
}
/**
* Convert KeyEvent to legacy key string format
*/
private function keyEventToString(KeyEvent $event): string
{
// If code is available, use it directly (contains escape sequence)
if ($event->code !== '') {
return $event->code;
}
// Otherwise map known keys to escape sequences
return match ($event->key) {
'ArrowUp' => "\033[A",
'ArrowDown' => "\033[B",
'ArrowRight' => "\033[C",
'ArrowLeft' => "\033[D",
'Enter' => "\n",
'Escape' => "\033",
'Tab' => "\t",
default => $event->key,
};
}
}

View File

@@ -8,6 +8,7 @@ use App\Framework\Console\CommandHistory;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\HistoryTab;
use App\Framework\Console\Layout\TerminalSize;
use App\Framework\Console\Screen\CursorControlCode;
use App\Framework\Console\Screen\ScreenControlCode;
use App\Framework\Console\TuiView;
@@ -18,9 +19,13 @@ use App\Framework\Discovery\ValueObjects\DiscoveredAttribute;
*/
final readonly class TuiRenderer
{
private MenuBar $menuBar;
public function __construct(
private ConsoleOutputInterface $output
) {
// Initialize menu bar with default items
$this->menuBar = new MenuBar(['Datei', 'Bearbeiten', 'Ansicht', 'Hilfe']);
}
/**
@@ -29,8 +34,17 @@ final readonly class TuiRenderer
public function render(TuiState $state, CommandHistory $history): void
{
$this->clearScreen();
$this->renderHeader();
// Get terminal size for layout
$terminalSize = TerminalSize::detect();
// Render menu bar at top
$this->renderMenuBar($state, $terminalSize->width);
// Render header (or skip if menu bar replaces it)
// $this->renderHeader();
// Render main content
match ($state->getCurrentView()) {
TuiView::CATEGORIES => $this->renderCategories($state),
TuiView::COMMANDS => $this->renderCommands($state),
@@ -40,6 +54,9 @@ final readonly class TuiRenderer
TuiView::DASHBOARD => $this->renderDashboard($state),
TuiView::HELP => $this->renderHelp($state),
};
// Render status line at bottom
$this->renderStatusLine($state, $terminalSize->width);
}
/**
@@ -443,4 +460,41 @@ final readonly class TuiRenderer
return floor($diff / 86400) . "d ago";
}
/**
* Render menu bar at the top
*/
private function renderMenuBar(TuiState $state, int $screenWidth): void
{
$this->menuBar->render($this->output, $state->getActiveMenu(), $screenWidth);
}
/**
* Render status line at the bottom
*/
private function renderStatusLine(TuiState $state, int $screenWidth): void
{
$statusText = $state->getStatus();
// Default status if empty
if ($statusText === '') {
$statusText = 'Bereit';
}
// Move cursor to last line
$terminalSize = TerminalSize::detect();
$this->output->write(CursorControlCode::POSITION->format($terminalSize->height, 1));
// Render status line with separator
$this->output->writeLine(str_repeat('─', $screenWidth), ConsoleColor::GRAY);
$this->output->writeLine($statusText, ConsoleColor::BRIGHT_BLUE);
}
/**
* Get menu bar instance
*/
public function getMenuBar(): MenuBar
{
return $this->menuBar;
}
}

View File

@@ -39,6 +39,11 @@ final class TuiState
private bool $formMode = false;
// Menu and status line
private string $statusText = '';
private ?string $activeMenu = null;
public function __construct()
{
}
@@ -318,4 +323,26 @@ final class TuiState
$this->showWorkflows = false;
$this->workflowProgress = [];
}
// Status line
public function getStatus(): string
{
return $this->statusText;
}
public function setStatus(string $text): void
{
$this->statusText = $text;
}
// Active menu
public function getActiveMenu(): ?string
{
return $this->activeMenu;
}
public function setActiveMenu(?string $menu): void
{
$this->activeMenu = $menu;
}
}

View File

@@ -269,7 +269,10 @@ final class ConsoleApplication
$this->output->writeWindowTitle("{$this->scriptName} - {$commandName}");
// Execute command via registry
return $this->commandRegistry->executeCommand($commandName, $arguments, $this->output);
$result = $this->commandRegistry->executeCommand($commandName, $arguments, $this->output);
// Handle ConsoleResult (new) or ExitCode (legacy)
return $this->processCommandResult($result);
} catch (CommandNotFoundException $e) {
return $this->errorHandler->handleCommandNotFound($commandName, $this->output);
@@ -291,6 +294,42 @@ final class ConsoleApplication
}
}
/**
* Process command result - supports both ConsoleResult and ExitCode
*/
private function processCommandResult(mixed $result): ExitCode
{
// New ConsoleResult pattern
if ($result instanceof \App\Framework\Console\Result\ConsoleResult) {
// Render result to output
$result->render($this->output);
// Return exit code from result
return $result->exitCode;
}
// Legacy ExitCode pattern
if ($result instanceof ExitCode) {
return $result;
}
// Legacy int pattern (for backwards compatibility)
if (is_int($result)) {
return ExitCode::from($result);
}
// Invalid return type - log warning and return error
if ($this->container->has(Logger::class)) {
$logger = $this->container->get(Logger::class);
$logger->warning('Command returned invalid result type', LogContext::withData([
'result_type' => get_debug_type($result),
'component' => 'ConsoleApplication',
]));
}
return ExitCode::GENERAL_ERROR;
}
private function showCommandUsage(string $commandName): void
{
try {
@@ -527,6 +566,7 @@ final class ConsoleApplication
// Create TUI components
$state = new TuiState();
$renderer = new TuiRenderer($this->output);
$menuBar = $renderer->getMenuBar();
$commandExecutor = new TuiCommandExecutor(
$this->output,
$this->commandRegistry,
@@ -537,7 +577,7 @@ final class ConsoleApplication
new CommandHelpGenerator(new ParameterInspector()),
$this->scriptName
);
$inputHandler = new TuiInputHandler($commandExecutor);
$inputHandler = new TuiInputHandler($commandExecutor, $menuBar);
// Erstelle TUI Instanz
$tui = new ConsoleTUI(

View File

@@ -51,6 +51,12 @@ final readonly class ConsoleOutput implements ConsoleOutputInterface
*/
public function writeWindowTitle(string $title, int $mode = 0): void
{
// Only emit terminal escape sequences when output goes to a real terminal
// Skip for pipes, redirects, MCP mode, Claude Code execution, and any programmatic use
if (!$this->isTerminal()) {
return;
}
$this->writeRaw("\033]$mode;{$title}\007");
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Result;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\ExitCode;
/**
* Composite Result - Combines multiple results
*
* Allows combining multiple console results into a single result.
* Useful for complex commands that need to display multiple sections.
*
* Exit code follows "worst wins" strategy:
* - If any result has FAILURE, composite has FAILURE
* - Otherwise SUCCESS
*
* Example:
* return new CompositeResult(
* TextResult::info("Starting process..."),
* TableResult::create(['Step', 'Status'], $steps),
* TextResult::success("Process completed!")
* );
*/
final readonly class CompositeResult implements ConsoleResult
{
/** @var array<ConsoleResult> */
private array $results;
public readonly ExitCode $exitCode;
public readonly array $data;
/**
* Create composite result with variadic constructor
*/
public function __construct(ConsoleResult ...$results)
{
$this->results = $results;
$this->exitCode = $this->aggregateExitCode($results);
$this->data = [
'result_count' => count($results),
'results' => array_map(fn(ConsoleResult $r) => $r->data, $results),
'exit_code' => $this->exitCode->value,
];
}
/**
* Get all results
*
* @return array<ConsoleResult>
*/
public function getResults(): array
{
return $this->results;
}
/**
* Render all results sequentially
*/
public function render(ConsoleOutputInterface $output): void
{
foreach ($this->results as $result) {
$result->render($output);
}
}
/**
* Aggregate exit codes - "worst wins" strategy
*
* @param array<ConsoleResult> $results
*/
private function aggregateExitCode(array $results): ExitCode
{
foreach ($results as $result) {
if ($result->exitCode === ExitCode::FAILURE) {
return ExitCode::FAILURE;
}
}
return ExitCode::SUCCESS;
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Result;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\ExitCode;
/**
* Console Result Interface
*
* Represents the result of a console command execution.
* Similar to HTTP ActionResult, but for console output.
*
* Results are value objects that encapsulate:
* - Exit code (success/failure)
* - Rendering logic
* - Metadata for testing/introspection
*/
interface ConsoleResult
{
/**
* Exit code for this result
*
* Determines the process exit code (0 = success, >0 = failure)
*/
public ExitCode $exitCode { get; }
/**
* Metadata for testing/introspection
*
* Returns array with result data that can be inspected in tests.
* Useful for asserting on result contents without rendering.
*
* @return array<string, mixed>
*/
public array $data { get; }
/**
* Render result to console output
*
* Implementations should write to the provided output interface.
*/
public function render(ConsoleOutputInterface $output): void;
}

View File

@@ -0,0 +1,188 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Result;
use App\Framework\Console\ConsoleStyle;
use App\Framework\Console\ExitCode;
/**
* Console Result Builder - Fluent API
*
* Provides a convenient fluent interface for building complex console results.
* Automatically creates CompositeResult when multiple components are added.
*
* Example:
* return ConsoleResultBuilder::create()
* ->addText('System Report', ConsoleStyle::SUCCESS)
* ->addDivider()
* ->addTable(['Metric', 'Value'], [
* ['CPU', '45%'],
* ['Memory', '2.3GB']
* ])
* ->addNewLine()
* ->addText('Generated: ' . date('Y-m-d H:i:s'))
* ->withExitCode(ExitCode::SUCCESS)
* ->build();
*/
final class ConsoleResultBuilder
{
/** @var array<ConsoleResult> */
private array $results = [];
private ExitCode $exitCode = ExitCode::SUCCESS;
private function __construct()
{
}
/**
* Create new builder instance
*/
public static function create(): self
{
return new self();
}
/**
* Add text result
*/
public function addText(
string $message,
ConsoleStyle $style = ConsoleStyle::INFO
): self {
$this->results[] = new TextResult($message, $style, $this->exitCode);
return $this;
}
/**
* Add success text
*/
public function addSuccess(string $message): self
{
$this->results[] = TextResult::success($message);
return $this;
}
/**
* Add error text
*/
public function addError(string $message): self
{
$this->results[] = TextResult::error($message);
$this->exitCode = ExitCode::FAILURE;
return $this;
}
/**
* Add warning text
*/
public function addWarning(string $message): self
{
$this->results[] = TextResult::warning($message);
return $this;
}
/**
* Add info text
*/
public function addInfo(string $message): self
{
$this->results[] = TextResult::info($message);
return $this;
}
/**
* Add plain text
*/
public function addPlain(string $message): self
{
$this->results[] = TextResult::plain($message);
return $this;
}
/**
* Add table result
*
* @param array<string> $headers
* @param array<array<string>> $rows
*/
public function addTable(
array $headers,
array $rows,
?string $title = null
): self {
$this->results[] = TableResult::create($headers, $rows, $title, $this->exitCode);
return $this;
}
/**
* Add divider (horizontal line)
*/
public function addDivider(string $char = '─', int $length = 80): self
{
$this->results[] = TextResult::plain(str_repeat($char, $length));
return $this;
}
/**
* Add empty line
*/
public function addNewLine(int $count = 1): self
{
for ($i = 0; $i < $count; $i++) {
$this->results[] = TextResult::plain('');
}
return $this;
}
/**
* Add existing result
*/
public function addResult(ConsoleResult $result): self
{
$this->results[] = $result;
return $this;
}
/**
* Set exit code for builder
*
* Note: Individual results may override this
*/
public function withExitCode(ExitCode $exitCode): self
{
$this->exitCode = $exitCode;
return $this;
}
/**
* Build final result
*
* Returns single result if only one component, CompositeResult otherwise
*/
public function build(): ConsoleResult
{
if (empty($this->results)) {
// Empty builder - return success with no output
return TextResult::plain('');
}
if (count($this->results) === 1) {
// Single result - return directly
return $this->results[0];
}
// Multiple results - wrap in CompositeResult
return new CompositeResult(...$this->results);
}
/**
* Convenience: Build and render immediately
*/
public function render(\App\Framework\Console\ConsoleOutputInterface $output): void
{
$this->build()->render($output);
}
}

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Result;
use App\Framework\Console\Components\Table;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\ExitCode;
/**
* Table Result - Tabular data output
*
* Displays data in a formatted table with headers and rows.
* Integrates with the existing Table component.
*
* Example:
* return TableResult::create(
* headers: ['ID', 'Name', 'Email'],
* rows: [['1', 'John', 'john@example.com']],
* title: 'Users'
* );
*/
final readonly class TableResult implements ConsoleResult
{
public readonly array $data;
/**
* Create table result
*
* @param array<string> $headers Table headers
* @param array<array<string>> $rows Table rows
* @param string|null $title Optional table title
* @param ExitCode $exitCode Exit code (default: SUCCESS)
*/
public function __construct(
public readonly array $headers,
public readonly array $rows,
public readonly ?string $title = null,
public readonly ExitCode $exitCode = ExitCode::SUCCESS,
) {
$this->data = [
'headers' => $this->headers,
'rows' => $this->rows,
'title' => $this->title,
'row_count' => count($this->rows),
'exit_code' => $this->exitCode->value,
];
}
/**
* Create table result (convenience factory)
*
* @param array<string> $headers
* @param array<array<string>> $rows
*/
public static function create(
array $headers,
array $rows,
?string $title = null,
ExitCode $exitCode = ExitCode::SUCCESS
): self {
return new self($headers, $rows, $title, $exitCode);
}
/**
* Create empty table with headers only
*
* @param array<string> $headers
*/
public static function empty(array $headers, ?string $title = null): self
{
return new self($headers, [], $title);
}
/**
* Render table to console output
*/
public function render(ConsoleOutputInterface $output): void
{
$table = new Table($output);
if ($this->title !== null) {
$table->setTitle($this->title);
}
$table->setHeaders($this->headers);
$table->setRows($this->rows);
$table->render();
}
}

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Result;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\ConsoleStyle;
use App\Framework\Console\ExitCode;
/**
* Text Result - Simple text output with styling
*
* The most basic console result type for displaying text messages.
* Supports different styles (success, error, warning, info) via factory methods.
*
* Example:
* return TextResult::success("User created successfully!");
* return TextResult::error("Failed to connect to database");
*/
final readonly class TextResult implements ConsoleResult
{
public readonly array $data;
/**
* Create text result
*
* Use factory methods (success/error/warning/info) for convenience.
*/
public function __construct(
public readonly string $message,
public readonly ConsoleStyle $style = ConsoleStyle::INFO,
public readonly ExitCode $exitCode = ExitCode::SUCCESS,
) {
$this->data = [
'message' => $this->message,
'style' => $this->style->value,
'exit_code' => $this->exitCode->value,
];
}
/**
* Create success result (green text, exit code 0)
*/
public static function success(string $message): self
{
return new self($message, ConsoleStyle::SUCCESS, ExitCode::SUCCESS);
}
/**
* Create error result (red text, exit code 1)
*/
public static function error(string $message): self
{
return new self($message, ConsoleStyle::ERROR, ExitCode::FAILURE);
}
/**
* Create warning result (yellow text, exit code 0)
*/
public static function warning(string $message): self
{
return new self($message, ConsoleStyle::WARNING, ExitCode::SUCCESS);
}
/**
* Create info result (blue text, exit code 0)
*/
public static function info(string $message): self
{
return new self($message, ConsoleStyle::INFO, ExitCode::SUCCESS);
}
/**
* Create plain result (no styling, exit code 0)
*/
public static function plain(string $message): self
{
return new self($message, ConsoleStyle::NONE, ExitCode::SUCCESS);
}
/**
* Render text to console output
*/
public function render(ConsoleOutputInterface $output): void
{
$output->writeLine($this->message, $this->style);
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Screen;
/**
* Mouse control codes for ANSI terminals (SGR mouse reports)
*/
enum MouseControlCode: string
{
// Enable/disable mouse reporting
case ENABLE_ALL = '?1000h'; // Enable mouse tracking
case DISABLE_ALL = '?1000l'; // Disable mouse tracking
case ENABLE_SGR = '?1006h'; // Enable SGR (Sixel Graphics Raster) mouse reports
case DISABLE_SGR = '?1006l'; // Disable SGR mouse reports
/**
* Format the mouse control code as ANSI sequence
*/
public function format(): string
{
return "\033[{$this->value}";
}
}

View File

@@ -64,10 +64,14 @@ final readonly class AppBootstrapper
// Register MemoryMonitor as singleton
$this->container->singleton(MemoryMonitor::class, $this->memoryMonitor);
// Only log context in development - production doesn't need this noise
$envType = EnvironmentType::fromEnvironment($env);
if ($envType->isDevelopment()) {
// Only log context in development - production doesn't need this noise
//$envType = EnvironmentType::fromEnvironment($env);
//if ($envType->isDevelopment()) {
if($typedConfig->app->type->isDevelopment()) {
// Fehleranzeige für die Entwicklung aktivieren
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
@@ -83,14 +87,6 @@ final readonly class AppBootstrapper
return false;
}, true, true);
register_shutdown_function(function () {
$error = error_get_last();
if ($error !== null) {
echo 'SHUTDOWN ERROR: ' . print_r($error, true);
}
});
}
}
@@ -123,6 +119,9 @@ final readonly class AppBootstrapper
$this->bootstrap();
$this->registerCliErrorHandler();
$ed = $this->container->get(EventDispatcher::class);
$this->container->instance(EventDispatcherInterface::class, $ed);
$consoleOutput = new ConsoleOutput();
$this->container->instance(ConsoleOutput::class, $consoleOutput);
@@ -195,33 +194,19 @@ final readonly class AppBootstrapper
}
/**
* Initialize environment with encryption support
* Initialize environment with smart priority handling
*
* Uses EnvironmentLoader which orchestrates:
* - Docker ENV vars loading (via $_ENV, $_SERVER)
* - .env file loading
* - Smart priority (Production: Docker ENV > .env, Development: .env > Docker ENV)
* - Automatic encryption support for .env.secrets
*/
private function initializeEnvironment(): Environment
{
// First, try to load basic environment to get encryption key
$basicEnv = Environment::fromFile($this->basePath . '/.env');
$encryptionKey = $basicEnv->get('ENCRYPTION_KEY');
$loader = new EncryptedEnvLoader();
// If we have an encryption key, use the encrypted loader
if ($encryptionKey !== null) {
try {
// These dependencies will be resolved later through the container
$randomGenerator = $this->container->get(RandomGenerator::class);
$encryptionFactory = new EncryptionFactory($randomGenerator);
$encryptedLoader = new EncryptedEnvLoader($encryptionFactory, $randomGenerator);
return $encryptedLoader->loadEnvironment($this->basePath, $encryptionKey);
} catch (\Throwable $e) {
// Fallback to basic environment if encryption fails
error_log("Failed to load encrypted environment: " . $e->getMessage());
return $basicEnv;
}
}
// Fallback to basic environment loading
return $basicEnv;
return $loader->load($this->basePath);
}
/**

View File

@@ -213,9 +213,23 @@ final readonly class ContainerBootstrapper
private function autowire(Container $container): void
{
// Discovery service bootstrapping
error_log("🔧 CONTAINER BOOTSTRAP: autowire() starting Discovery");
$clock = $container->get(Clock::class);
$bootstrapper = new DiscoveryServiceBootstrapper($container, $clock);
// Create a simple logger for Discovery phase
// In MCP mode, use NullHandler to suppress output
$isMcpMode = getenv('MCP_SERVER_MODE') === '1';
$handlers = $isMcpMode
? [new \App\Framework\Logging\Handlers\NullHandler()]
: [new \App\Framework\Logging\Handlers\ConsoleHandler()];
$logger = new \App\Framework\Logging\DefaultLogger(
minLevel: \App\Framework\Logging\LogLevel::DEBUG,
handlers: $handlers,
processorManager: new \App\Framework\Logging\ProcessorManager(),
contextManager: new \App\Framework\Logging\LogContextManager()
);
$bootstrapper = new DiscoveryServiceBootstrapper($container, $clock, $logger);
$results = $bootstrapper->bootstrap();
}

View File

@@ -4,12 +4,14 @@ declare(strict_types=1);
namespace App\Framework\Core\Events;
use App\Framework\Core\ValueObjects\Version;
final readonly class ApplicationBooted
{
public function __construct(
public \DateTimeImmutable $bootTime,
public string $environment,
public string $version = 'dev',
public Version $version,
public \DateTimeImmutable $occurredAt = new \DateTimeImmutable()
) {
}

View File

@@ -3,7 +3,37 @@ declare(strict_types=1);
namespace App\Framework\Core\Helper;
final class Str
{
use Stringable;
final readonly class Str
{
public static function len(Stringable|string $value): int
{
#grapheme_strlen($value);
return mb_strlen((string)$value);
}
public static function empty(Stringable|string $value): bool
{
return self::len($value) === 0;
}
public static function slice(Stringable|string $value, int $start, ?int $length = null): string
{
return grapheme_substr((string)$value, $start, $length);
}
public static function trim(Stringable|string $value): string
{
return mb_trim((string)$value);
}
/**
* ltrim
* rtrim
* ucfirst
* lcfirst
* upper
* lower
*/
}

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Framework\Core\ValueObjects;
use App\Framework\Core\Helper\Str;
/**
* Idempotency Key Value Object
*
@@ -32,8 +34,8 @@ namespace App\Framework\Core\ValueObjects;
*/
final readonly class IdempotencyKey
{
private const MIN_LENGTH = 16;
private const MAX_LENGTH = 255;
private const int MIN_LENGTH = 16;
private const int MAX_LENGTH = 255;
private function __construct(
public string $value
@@ -127,7 +129,7 @@ final readonly class IdempotencyKey
*/
public function toMasked(): string
{
$length = strlen($this->value);
$length = Str::len($this->value);
if ($length <= 8) {
return str_repeat('*', $length);
@@ -144,7 +146,7 @@ final readonly class IdempotencyKey
*/
private function validate(): void
{
$length = strlen($this->value);
$length = Str::len($this->value);
if ($length < self::MIN_LENGTH) {
throw new \InvalidArgumentException(

View File

@@ -4,5 +4,11 @@ namespace App\Framework\Core\ValueObjects\PhoneNumber;
enum PhoneNumberFormat
{
case E164;
case INTERNATIONAL;
case NATIONAL;
case RFC3966;
}

View File

@@ -331,4 +331,4 @@ try {
3. **Validation**: Use `Byte` objects for all size-related validation logic
4. **Logging**: Use `toHumanReadable()` for user-friendly log messages
5. **APIs**: Accept string formats and parse them into `Byte` objects
6. **Performance**: Create `Byte` objects lazily when needed, store raw bytes when possible
6. **Performance**: Create `Byte` objects lazily when needed, store raw bytes when possible

View File

@@ -13,7 +13,7 @@ use InvalidArgumentException;
* Represents a cryptographically secure token with metadata about its
* generation, format, and intended use.
*/
final readonly class SecureToken
final readonly class SecureToken implements Stringable
{
private DateTimeImmutable $createdAt;

View File

@@ -16,7 +16,7 @@ use InvalidArgumentException;
* - Optimized for horizontal scaling and collision resistance
* - Always starts with 'c' for collision-resistant
*/
final readonly class Cuid
final readonly class Cuid implements Stringable
{
public const int LENGTH = 25;
public const string PREFIX = 'c';

View File

@@ -16,10 +16,13 @@ use App\Framework\DDoS\Response\AdaptiveResponseSystem;
use App\Framework\DDoS\Response\ValueObjects\DDoSResponse;
use App\Framework\DDoS\ValueObjects\DDoSAssessment;
use App\Framework\DDoS\ValueObjects\ThreatLevel;
use App\Framework\ErrorHandling\SecurityEventLogger;
use App\Framework\Exception\SecurityLogLevel;
use App\Framework\Http\HttpRequest;
use App\Framework\Http\IpAddress;
use App\Framework\Logging\Logger;
use App\Framework\Logging\Processors\SecurityEventProcessor;
use App\Framework\Logging\ValueObjects\LogContext;
use App\Framework\Logging\ValueObjects\SecurityContext;
use App\Framework\Performance\PerformanceService;
/**
@@ -36,7 +39,7 @@ final class DDoSProtectionEngine
private readonly TrafficPatternAnalyzer $trafficAnalyzer,
private readonly GeoAnomalyDetector $geoDetector,
private readonly AdaptiveResponseSystem $responseSystem,
private readonly SecurityEventLogger $securityLogger,
private readonly SecurityEventProcessor $processor,
private readonly PerformanceService $performance,
private readonly Logger $logger,
private readonly Clock $clock,
@@ -187,19 +190,55 @@ final class DDoSProtectionEngine
}
/**
* Log high-confidence security threats
* Log high-confidence security threats using OWASP-compliant logging
*/
private function logSecurityThreat(DDoSAssessment $assessment, HttpRequest $request): void
{
/*$this->securityLogger->logSecurityEvent([
'event_type' => 'ddos_threat_detected',
'threat_level' => $assessment->threatLevel->name,
'confidence' => $assessment->confidence,
'client_ip' => $assessment->clientIp,
'attack_patterns' => array_map(fn ($p) => $p->name, $assessment->attackPatterns),
'request_path' => $request->path,
'user_agent' => $request->headers->get('User-Agent'),
'timestamp' => $assessment->timestamp->format('c'),
]);*/
// Map ThreatLevel to SecurityLogLevel
$securityLevel = $this->mapThreatLevelToSecurityLogLevel($assessment->threatLevel);
// Create SecurityContext for OWASP-compliant logging
$securityContext = SecurityContext::forIntrusion(
eventId: 'ddos_threat_detected',
description: "DDoS threat detected from {$assessment->clientIp->value}",
level: $securityLevel,
requiresAlert: $assessment->threatLevel === ThreatLevel::CRITICAL,
eventData: [
'threat_level' => $assessment->threatLevel->name,
'confidence' => $assessment->confidence,
'client_ip' => $assessment->clientIp->value,
'attack_patterns' => array_map(fn($p) => $p->name, $assessment->attackPatterns),
'request_path' => $request->path,
'recommended_action' => $assessment->recommendedAction->name,
'analysis_results' => $assessment->analysisResults,
'processing_time_ms' => $assessment->processingTime->toMilliseconds(),
]
)->withRequestInfo(
sourceIp: $assessment->clientIp->value,
userAgent: $request->headers->get('User-Agent')
);
// Map SecurityLogLevel to framework LogLevel
$logLevel = $this->processor->mapSecurityLevelToLogLevel($securityLevel);
// Log with SecurityContext
$this->logger->log(
$logLevel,
"DDoS threat detected from {$assessment->clientIp->value}",
LogContext::empty()->withSecurityContext($securityContext)
);
}
/**
* Map DDoS ThreatLevel to SecurityLogLevel
*/
private function mapThreatLevelToSecurityLogLevel(ThreatLevel $threatLevel): SecurityLogLevel
{
return match ($threatLevel) {
ThreatLevel::LOW => SecurityLogLevel::INFO,
ThreatLevel::MEDIUM => SecurityLogLevel::WARN,
ThreatLevel::HIGH => SecurityLogLevel::ERROR,
ThreatLevel::CRITICAL => SecurityLogLevel::FATAL,
};
}
}

View File

@@ -297,11 +297,11 @@ final readonly class AsyncDatabaseAdapter
// Table size (MySQL specific)
$sizeResult = $this->connection->queryOne(
"SELECT
"SELECT
ROUND(((DATA_LENGTH + INDEX_LENGTH) / 1024 / 1024), 2) AS size_mb,
DATA_LENGTH,
INDEX_LENGTH
FROM information_schema.TABLES
FROM information_schema.TABLES
WHERE table_schema = DATABASE() AND table_name = ?",
[$table]
);

View File

@@ -398,11 +398,11 @@ final readonly class AsyncDatabaseDecorator implements ConnectionInterface
// Table size (MySQL specific)
$sizeResult = $this->connection->queryOne(
"SELECT
"SELECT
ROUND(((DATA_LENGTH + INDEX_LENGTH) / 1024 / 1024), 2) AS size_mb,
DATA_LENGTH,
INDEX_LENGTH
FROM information_schema.TABLES
FROM information_schema.TABLES
WHERE table_schema = DATABASE() AND table_name = ?",
[$table]
);

View File

@@ -23,11 +23,11 @@ final readonly class DatabaseConfigInitializer
$driverConfig = new DriverConfig(
driverType: DriverType::from($this->env->getString(EnvKey::DB_DRIVER, 'pgsql')),
host: $this->env->getString(EnvKey::DB_HOST, 'db'),
port: $this->env->getInt(EnvKey::DB_PORT, 3306),
port: $this->env->getInt(EnvKey::DB_PORT, 5432),
database: $this->env->getRequired(EnvKey::DB_DATABASE),
username: $this->env->getRequired(EnvKey::DB_USERNAME),
password: $this->env->getRequired(EnvKey::DB_PASSWORD),
charset: $this->env->getString(EnvKey::DB_CHARSET, 'utf8mb4'),
charset: $this->env->getString(EnvKey::DB_CHARSET, 'utf8'),
);
$poolConfig = new PoolConfig(

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Framework\Database\Migration;
use App\Framework\Config\AppConfig;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\Migration\Services\MigrationDatabaseManager;
use App\Framework\Database\Migration\Services\MigrationErrorAnalyzer;
@@ -43,6 +44,7 @@ final readonly class MigrationRunner
private DatabasePlatform $platform,
private Clock $clock,
private UlidGenerator $ulidGenerator,
private AppConfig $appConfig,
?MigrationTableConfig $tableConfig = null,
?Logger $logger = null,
?OperationTracker $operationTracker = null,
@@ -75,7 +77,8 @@ final readonly class MigrationRunner
$this->validator = new MigrationValidator(
$this->connection,
$this->platform
$this->platform,
$this->appConfig
);
$this->errorAnalyzer = new MigrationErrorAnalyzer();

View File

@@ -56,7 +56,7 @@ final class ReplicationLagDetector
try {
// Check if heartbeat table exists
$tableExists = $replica->queryScalar(
"SELECT COUNT(*) FROM information_schema.tables
"SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = 'replication_heartbeat'"
);

View File

@@ -0,0 +1,214 @@
<?php
declare(strict_types=1);
namespace App\Framework\Deployment\Webhook\Http;
use App\Framework\Attributes\Route;
use App\Framework\Deployment\Pipeline\Services\DeploymentPipelineService;
use App\Framework\Deployment\Pipeline\ValueObjects\DeploymentEnvironment;
use App\Framework\EventBus\EventBus;
use App\Framework\Http\JsonResult;
use App\Framework\Http\Method;
use App\Framework\Http\Request;
use App\Framework\Http\Status;
use App\Framework\Logging\Logger;
/**
* Deployment Webhook Controller
*
* Receives webhooks from Gitea Actions after successful builds and triggers
* the framework's deployment pipeline using the Process module.
*
* This is the missing integration layer that enables automatic deployment
* on git push by connecting Gitea Actions to the existing deployment infrastructure.
*/
final readonly class DeploymentWebhookController
{
public function __construct(
private DeploymentPipelineService $pipelineService,
private EventBus $eventBus,
private Logger $logger
) {}
/**
* Handle deployment webhook from Gitea Actions
*
* Expected payload from Gitea Actions:
* {
* "image_tag": "abc1234-1234567890",
* "commit_sha": "abc1234",
* "branch": "main",
* "environment": "production",
* "webhook_token": "secret_token"
* }
*
* @route POST /api/deployment/webhook
*/
#[Route(path: '/api/deployment/webhook', method: Method::POST)]
public function handleWebhook(Request $request): JsonResult
{
$this->logger->info('[Deployment Webhook] Received webhook request', [
'ip' => $request->server->getRemoteAddr(),
'user_agent' => $request->server->getUserAgent()->toString(),
]);
try {
// Parse and validate webhook payload
$payload = $this->parseAndValidatePayload($request);
// Validate webhook token for security
if (!$this->validateWebhookToken($payload['webhook_token'] ?? '')) {
$this->logger->warning('[Deployment Webhook] Invalid webhook token', [
'ip' => $request->server->getRemoteAddr(),
]);
return new JsonResult(
data: ['error' => 'Invalid webhook token'],
status: Status::FORBIDDEN
);
}
// Parse environment
$environment = $this->parseEnvironment($payload['environment'] ?? 'production');
$this->logger->info('[Deployment Webhook] Starting deployment pipeline', [
'environment' => $environment->value,
'image_tag' => $payload['image_tag'],
'commit_sha' => $payload['commit_sha'],
'branch' => $payload['branch'],
]);
// Trigger existing deployment pipeline service
// This uses the framework's Process module via AnsibleDeployStage
$result = $this->pipelineService->execute($environment);
if ($result->isSuccess()) {
$this->logger->info('[Deployment Webhook] Pipeline executed successfully', [
'pipeline_id' => $result->pipelineId->value,
'environment' => $environment->value,
'duration' => $result->totalDuration->toMilliseconds() . 'ms',
]);
return new JsonResult(
data: [
'status' => 'success',
'message' => 'Deployment pipeline started successfully',
'pipeline_id' => $result->pipelineId->value,
'environment' => $environment->getDisplayName(),
'duration' => $result->totalDuration->toMilliseconds(),
'stages_completed' => $result->getCompletedStagesCount(),
// SSE endpoint for real-time status tracking
'status_stream_url' => "/api/deployment/stream/{$result->pipelineId->value}",
],
status: Status::OK
);
}
// Pipeline failed or was rolled back
$this->logger->error('[Deployment Webhook] Pipeline failed', [
'pipeline_id' => $result->pipelineId->value,
'status' => $result->status->value,
'error' => $result->error ?? 'Unknown error',
]);
return new JsonResult(
data: [
'status' => 'failed',
'message' => 'Deployment pipeline failed',
'pipeline_id' => $result->pipelineId->value,
'pipeline_status' => $result->status->value,
'error' => $result->error ?? 'Unknown error',
'failed_stage' => $result->getFailedStage()?->stage->value,
'was_rolled_back' => $result->wasRolledBack(),
],
status: Status::INTERNAL_SERVER_ERROR
);
} catch (\InvalidArgumentException $e) {
$this->logger->warning('[Deployment Webhook] Invalid request payload', [
'error' => $e->getMessage(),
]);
return new JsonResult(
data: ['error' => $e->getMessage()],
status: Status::BAD_REQUEST
);
} catch (\Throwable $e) {
$this->logger->error('[Deployment Webhook] Unexpected error', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
return new JsonResult(
data: [
'error' => 'Internal server error during deployment',
'message' => $e->getMessage(),
],
status: Status::INTERNAL_SERVER_ERROR
);
}
}
/**
* Parse and validate webhook payload
*
* @throws \InvalidArgumentException if payload is invalid
*/
private function parseAndValidatePayload(Request $request): array
{
$body = $request->parsedBody;
if ($body === null) {
throw new \InvalidArgumentException('Request body is required');
}
$payload = $body->toArray();
// Validate required fields
$requiredFields = ['image_tag', 'commit_sha', 'branch'];
foreach ($requiredFields as $field) {
if (empty($payload[$field])) {
throw new \InvalidArgumentException("Missing required field: {$field}");
}
}
return $payload;
}
/**
* Validate webhook token for security
*
* Compares provided token against configured secret token to prevent
* unauthorized deployment triggers.
*/
private function validateWebhookToken(string $token): bool
{
// Get webhook secret from environment
$expectedToken = $_ENV['DEPLOYMENT_WEBHOOK_TOKEN'] ?? '';
if (empty($expectedToken)) {
$this->logger->warning('[Deployment Webhook] DEPLOYMENT_WEBHOOK_TOKEN not configured');
return false;
}
// Constant-time comparison to prevent timing attacks
return hash_equals($expectedToken, $token);
}
/**
* Parse deployment environment from string
*
* @throws \InvalidArgumentException if environment is invalid
*/
private function parseEnvironment(string $environment): DeploymentEnvironment
{
return match (strtolower($environment)) {
'development', 'dev' => DeploymentEnvironment::DEVELOPMENT,
'staging', 'stage' => DeploymentEnvironment::STAGING,
'production', 'prod' => DeploymentEnvironment::PRODUCTION,
default => throw new \InvalidArgumentException(
"Invalid environment: {$environment}. Must be one of: development, staging, production"
),
};
}
}

View File

@@ -17,6 +17,8 @@ use App\Framework\DI\Container;
use App\Framework\Discovery\Cache\DiscoveryCacheIdentifiers;
use App\Framework\Discovery\Factory\DiscoveryServiceFactory;
use App\Framework\Discovery\Results\DiscoveryRegistry;
use App\Framework\Logging\Logger;
use App\Framework\Logging\ValueObjects\LogContext;
/**
* Bootstrapper für den Discovery-Service
@@ -26,7 +28,8 @@ final readonly class DiscoveryServiceBootstrapper
{
public function __construct(
private Container $container,
private Clock $clock
private Clock $clock,
private Logger $logger
) {
}
@@ -48,23 +51,23 @@ final readonly class DiscoveryServiceBootstrapper
$currentContext = ExecutionContext::detect();
$contextString = $currentContext->getType()->value;
// TEMPORARY DEBUG LOGGING
error_log("🔍 DISCOVERY DEBUG: Context detected = {$contextString}");
error_log("🔍 DISCOVERY DEBUG: Source path = " . $pathProvider->getSourcePath());
// Debug logging via framework Logger
$this->logger->debug("Context detected = {$contextString}", LogContext::withData([
'source_path' => $pathProvider->getSourcePath()
]));
// Direkter Cache-Check mit expliziter toArray/fromArray Serialisierung
$defaultPaths = [$pathProvider->getSourcePath()];
$cacheKey = DiscoveryCacheIdentifiers::fullDiscoveryKey($defaultPaths, $contextString);
error_log("🔍 DISCOVERY DEBUG: Cache key = {$cacheKey->toString()}");
$this->logger->debug("Cache key = {$cacheKey->toString()}");
$cachedItem = $cache->get($cacheKey);
error_log("🔍 DISCOVERY DEBUG: Cache hit = " . ($cachedItem->isHit ? 'YES' : 'NO'));
$this->logger->debug("Cache hit = " . ($cachedItem->isHit ? 'YES' : 'NO'));
if ($cachedItem->isHit) {
error_log("🔍 DISCOVERY DEBUG: Loading from cache...");
$this->logger->debug("Loading from cache...");
// Ensure DiscoveryRegistry class is loaded before attempting deserialization
if (! class_exists(DiscoveryRegistry::class, true)) {
$cachedRegistry = null;
@@ -92,7 +95,7 @@ final readonly class DiscoveryServiceBootstrapper
if ($cachedRegistry !== null && ! $cachedRegistry->isEmpty()) {
$routeCount = count($cachedRegistry->attributes->get(\App\Framework\Attributes\Route::class));
error_log("🔍 DISCOVERY DEBUG: Cached registry loaded - Route count: {$routeCount}");
$this->logger->debug("Cached registry loaded - Route count: {$routeCount}");
$this->container->singleton(DiscoveryRegistry::class, $cachedRegistry);
@@ -109,18 +112,22 @@ final readonly class DiscoveryServiceBootstrapper
}
// Fallback: Vollständige Discovery durchführen
error_log("🔍 DISCOVERY DEBUG: Performing fresh discovery...");
$this->logger->debug("Performing fresh discovery...");
$results = $this->performBootstrap($pathProvider, $cache, $discoveryConfig);
error_log("🔍 DISCOVERY DEBUG: Discovery completed - isEmpty: " . ($results->isEmpty() ? 'YES' : 'NO'));
$this->logger->debug("Discovery completed", LogContext::withData([
'is_empty' => $results->isEmpty()
]));
// Nach der Discovery explizit in unserem eigenen Cache-Format speichern
$consoleCommandCount = count($results->attributes->get(\App\Framework\Console\ConsoleCommand::class));
$routeCount = count($results->attributes->get(\App\Framework\Attributes\Route::class));
$initializerCount = count($results->attributes->get(\App\Framework\DI\Initializer::class));
error_log("🔍 DISCOVERY DEBUG: Found {$routeCount} routes");
error_log("🔍 DISCOVERY DEBUG: Found {$consoleCommandCount} console commands");
error_log("🔍 DISCOVERY DEBUG: Found {$initializerCount} initializers");
$this->logger->debug("Discovery results", LogContext::withData([
'routes' => $routeCount,
'console_commands' => $consoleCommandCount,
'initializers' => $initializerCount
]));
// Only cache if we found meaningful results
// An empty discovery likely indicates initialization timing issues

View File

@@ -97,7 +97,7 @@ final readonly class DiscoveredAttribute
$filePath = null;
$fileData = $data['file'] ?? $data['filePath'] ?? null;
if ($fileData !== null && ! empty($fileData)) {
if (! empty($fileData)) {
if (is_object($fileData)) {
// Check if it's a complete FilePath object
if ($fileData instanceof FilePath) {
@@ -232,9 +232,6 @@ final readonly class DiscoveredAttribute
} elseif ($value instanceof \UnitEnum) {
// Handle PHP unit enums
$sanitizedAdditionalData[$key] = $value->name;
} else {
// Skip unsupported objects to prevent serialization issues
continue;
}
} else {
$sanitizedAdditionalData[$key] = $value;

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Framework\Encryption;
use App\Framework\Random\RandomGenerator;
use App\Framework\Random\SecureRandomGenerator;
use RuntimeException;
/**
@@ -13,9 +14,8 @@ use RuntimeException;
final readonly class EncryptionFactory
{
public function __construct(
private RandomGenerator $randomGenerator
) {
}
private RandomGenerator $randomGenerator = new SecureRandomGenerator,
) {}
/**
* Create the best available encryption method

View File

@@ -8,6 +8,7 @@ use App\Framework\Config\Environment;
use App\Framework\Config\EnvKey;
use App\Framework\DI\Container;
use App\Framework\ErrorAggregation\ErrorAggregatorInterface;
use App\Framework\ErrorHandling\ValueObjects\ErrorMetadata;
use App\Framework\ErrorHandling\View\ApiErrorRenderer;
use App\Framework\ErrorHandling\View\ErrorResponseFactory;
use App\Framework\ErrorHandling\View\ErrorTemplateRenderer;
@@ -46,6 +47,7 @@ final readonly class ErrorHandler
private RequestIdGenerator $requestIdGenerator,
private ErrorAggregatorInterface $errorAggregator,
private ErrorReporterInterface $errorReporter,
private ErrorHandlerManager $handlerManager,
?Logger $logger = null,
?bool $isDebugMode = null,
?SecurityEventHandler $securityHandler = null
@@ -57,19 +59,60 @@ final readonly class ErrorHandler
$appConfig = $container->get(\App\Framework\Config\AppConfig::class);
$this->logger = new ErrorLogger($logger, $appConfig);
$this->securityHandler = $securityHandler ?? SecurityEventHandler::createDefault($logger, $appConfig);
$this->securityHandler = $securityHandler ?? SecurityEventHandler::createDefault($logger);
}
public function register(): void
{
set_exception_handler($this->handleException(...));
set_error_handler($this->handleError(...));
register_shutdown_function($this->handleShutdown(...));
}
public function createHttpResponse(\Throwable $e, ?MiddlewareContext $context = null): Response
{
// Try specialized handlers first
$handlingResult = $this->handlerManager->handle($e);
if ($handlingResult->handled) {
// Handler chain processed the exception
$firstResult = $handlingResult->getFirstResult();
if ($firstResult !== null) {
return $this->createResponseFromHandlerResult($firstResult, $e);
}
}
// Fallback to legacy error handling
return $this->createLegacyHttpResponse($e, $context);
}
private function createResponseFromHandlerResult(
Handlers\HandlerResult $result,
\Throwable $exception
): Response {
$statusCode = $result->statusCode ?? 500;
$responseFactory = $this->createResponseFactory();
$isApiRequest = $responseFactory->isApiRequest();
if ($isApiRequest) {
return new \App\Framework\Http\Responses\JsonResponse([
'error' => $result->message,
'data' => $result->data
], $statusCode);
}
// For HTML requests, still use legacy rendering
$errorHandlerContext = $this->createErrorHandlerContext($exception);
return $responseFactory->createResponseFromHandlerContext(
$errorHandlerContext,
$this->isDebugMode,
false
);
}
private function createLegacyHttpResponse(\Throwable $e, ?MiddlewareContext $context = null): Response
{
// Handle ValidationException with form-specific logic
if ($e instanceof ValidationException) {
@@ -122,7 +165,7 @@ final readonly class ErrorHandler
return new RedirectResponse($refererUrl);
}
public function handleException(Throwable $e): never
public function handleException(Throwable $e): void
{
$errorHandlerContext = $this->createErrorHandlerContext($e);
@@ -149,7 +192,7 @@ final readonly class ErrorHandler
// Sende die Fehlermeldung
$this->emitter->emit($response);
exit(1);
// Let PHP invoke shutdown functions naturally - do not call exit(1)
}
public function handleError(int $errno, string $errstr, string $errfile, int $errline): void
@@ -158,25 +201,6 @@ final readonly class ErrorHandler
$this->handleException($exception);
}
public function handleShutdown(): void
{
$error = error_get_last();
if ($error && $this->isFatalError($error['type'])) {
$exception = new \ErrorException(
$error['message'],
0,
$error['type'],
$error['file'],
$error['line']
);
$this->handleException($exception);
}
}
private function isFatalError(int $errno): bool
{
return in_array($errno, [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR], true);
}
private function createErrorHandlerContext(Throwable $exception, ?MiddlewareContext $context = null): ErrorHandlerContext
{
@@ -259,55 +283,65 @@ final readonly class ErrorHandler
return RequestContext::fromGlobals();
}
/**
* @return array<string, mixed>
*/
private function createExceptionMetadata(Throwable $exception): array
private function createExceptionMetadata(Throwable $exception): ErrorMetadata
{
$metadata = [
'exception_class' => get_class($exception),
'error_level' => $this->determineErrorSeverity($exception)->name,
];
$exceptionClass = get_class($exception);
$errorLevel = $this->determineErrorSeverity($exception);
$httpStatus = $this->determineHttpStatus($exception);
// Enhanced: Add ErrorCode metadata if FrameworkException
// Enhanced metadata for FrameworkException with ErrorCode
if ($exception instanceof FrameworkException) {
$errorCode = $exception->getErrorCode();
if ($errorCode !== null) {
$metadata['error_code'] = $errorCode->getValue();
$metadata['error_category'] = $errorCode->getCategory();
$metadata['error_severity'] = $errorCode->getSeverity()->value;
$metadata['is_recoverable'] = $errorCode->isRecoverable();
$recoveryHint = $this->isDebugMode ? $errorCode->getRecoveryHint() : null;
// Add recovery hint for debug mode or API responses
if ($this->isDebugMode) {
$metadata['recovery_hint'] = $errorCode->getRecoveryHint();
}
$metadata = ErrorMetadata::enhanced(
exceptionClass: $exceptionClass,
errorLevel: $errorLevel,
httpStatus: $httpStatus,
errorCode: $errorCode->getValue(),
errorCategory: $errorCode->getCategory(),
errorSeverity: $errorCode->getSeverity()->value,
isRecoverable: $errorCode->isRecoverable(),
recoveryHint: $recoveryHint
);
// Add Retry-After header if applicable
$retryAfter = $errorCode->getRetryAfterSeconds();
if ($retryAfter !== null) {
$metadata['additional_headers'] = array_merge(
$metadata['additional_headers'] ?? [],
['Retry-After' => (string) $retryAfter]
);
$metadata = $metadata->withAdditionalHeaders([
'Retry-After' => (string) $retryAfter
]);
}
return $this->addExceptionSpecificHeaders($exception, $metadata);
}
}
// HTTP-Status-Code: ErrorCode-based first, then fallback to exception-type mapping
$metadata['http_status'] = $this->determineHttpStatus($exception);
// Basic metadata for non-FrameworkException or FrameworkException without ErrorCode
$metadata = ErrorMetadata::basic(
exceptionClass: $exceptionClass,
errorLevel: $errorLevel,
httpStatus: $httpStatus
);
// Zusätzliche Header für spezielle Exceptions
return $this->addExceptionSpecificHeaders($exception, $metadata);
}
private function addExceptionSpecificHeaders(
Throwable $exception,
ErrorMetadata $metadata
): ErrorMetadata {
// RateLimitExceededException headers
if ($exception instanceof \App\Framework\Exception\Http\RateLimitExceededException) {
$metadata['additional_headers'] = array_merge(
$metadata['additional_headers'] ?? [],
$metadata = $metadata->withAdditionalHeaders(
$exception->getRateLimitHeaders()
);
}
// InvalidContentTypeException headers
if ($exception instanceof \App\Framework\Exception\Http\InvalidContentTypeException) {
$metadata['additional_headers'] = array_merge(
$metadata['additional_headers'] ?? [],
$metadata = $metadata->withAdditionalHeaders(
$exception->getResponseHeaders()
);
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Framework\ErrorHandling;
use App\Framework\Attributes\Initializer;
use App\Framework\DI\Container;
use App\Framework\ErrorHandling\Handlers\DatabaseErrorHandler;
use App\Framework\ErrorHandling\Handlers\FallbackErrorHandler;
use App\Framework\ErrorHandling\Handlers\HttpErrorHandler;
use App\Framework\ErrorHandling\Handlers\ValidationErrorHandler;
use App\Framework\Logging\Logger;
/**
* Initializes the error handler system
*
* Registers all error handlers in priority order
*/
final readonly class ErrorHandlerInitializer
{
#[Initializer]
public function initialize(Container $container): ErrorHandlerManager
{
$logger = $container->get(Logger::class);
$registry = new ErrorHandlerRegistry();
$manager = new ErrorHandlerManager($registry);
// Register all handlers at once (will be sorted by priority internally)
return $manager->register(
new ValidationErrorHandler(), // CRITICAL priority
new DatabaseErrorHandler($logger), // HIGH priority
new HttpErrorHandler(), // NORMAL priority
new FallbackErrorHandler($logger) // LOWEST priority
);
}
}

View File

@@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
namespace App\Framework\ErrorHandling;
use App\Framework\ErrorHandling\Handlers\ErrorHandlerInterface;
use App\Framework\ErrorHandling\Handlers\ErrorHandlerPriority;
use App\Framework\ErrorHandling\Handlers\HandlerRegistration;
/**
* Manager for error handler chain
*
* Orchestrates multiple error handlers with priority-based execution
*
* Immutable: All modification methods return new instances
*/
final readonly class ErrorHandlerManager
{
/**
* @param HandlerRegistration[] $handlers
*/
public function __construct(
private ErrorHandlerRegistry $registry,
private array $handlers = []
) {}
/**
* Register one or more error handlers (immutable)
*
* @param ErrorHandlerInterface ...$handlers One or more handlers to register
* @return self New instance with handlers registered
*/
public function register(ErrorHandlerInterface ...$handlers): self
{
$newHandlers = $this->handlers;
$newRegistry = $this->registry;
foreach ($handlers as $handler) {
$priority = $handler->getPriority();
$registration = new HandlerRegistration($handler, $priority);
$newHandlers[] = $registration;
// Register in registry
$newRegistry = $newRegistry->register($handler->getName(), $handler);
}
// Sort by priority (highest first)
usort($newHandlers, fn(HandlerRegistration $a, HandlerRegistration $b) =>
$a->comparePriority($b)
);
return new self($newRegistry, $newHandlers);
}
/**
* Handle an exception through the handler chain
*/
public function handle(\Throwable $exception): ErrorHandlingResult
{
$results = [];
$handled = false;
foreach ($this->handlers as $registration) {
// Check if handler can handle this exception
if (!$registration->canHandle($exception)) {
continue;
}
try {
// Handle exception
$result = $registration->handle($exception);
$results[] = $result;
// Mark as handled if handler handled it
if ($result->handled) {
$handled = true;
}
// Stop if handler marked as final
if ($result->isFinal) {
break;
}
} catch (\Throwable $handlerException) {
// Handler itself failed - log but continue
error_log(sprintf(
'[ErrorHandlerManager] Handler %s failed: %s',
$registration->getHandlerName(),
$handlerException->getMessage()
));
}
}
return new ErrorHandlingResult(
handled: $handled,
results: $results,
exception: $exception
);
}
/**
* Get all registered handlers
*
* @return ErrorHandlerInterface[]
*/
public function getHandlers(): array
{
return array_map(
fn(HandlerRegistration $registration) => $registration->handler,
$this->handlers
);
}
/**
* Remove a handler (immutable)
*
* @return self New instance without the specified handler
*/
public function unregister(ErrorHandlerInterface $handler): self
{
$newHandlers = array_filter(
$this->handlers,
fn(HandlerRegistration $registration) => $registration->handler !== $handler
);
// Remove from registry (registry handles its own immutability)
$newRegistry = $this->registry->unregister($handler->getName());
return new self($newRegistry, array_values($newHandlers));
}
/**
* Clear all handlers (immutable)
*
* @return self New instance with no handlers
*/
public function clear(): self
{
$newRegistry = $this->registry->clear();
return new self($newRegistry, []);
}
/**
* Get handler statistics
*/
public function getStatistics(): array
{
$stats = [
'total_handlers' => count($this->handlers),
'handlers_by_priority' => [],
'handlers' => []
];
foreach ($this->handlers as $registration) {
$priorityValue = $registration->priority->value;
// Count by priority
if (!isset($stats['handlers_by_priority'][$priorityValue])) {
$stats['handlers_by_priority'][$priorityValue] = 0;
}
$stats['handlers_by_priority'][$priorityValue]++;
// Handler details
$stats['handlers'][] = [
'name' => $registration->getHandlerName(),
'class' => get_class($registration->handler),
'priority' => $priorityValue
];
}
return $stats;
}
}

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Framework\ErrorHandling;
use App\Framework\ErrorHandling\Handlers\ErrorHandlerInterface;
/**
* Registry for named error handlers
*
* Allows handlers to be registered and retrieved by name
*
* Immutable: All modification methods return new instances
*/
final readonly class ErrorHandlerRegistry
{
/**
* @param array<string, ErrorHandlerInterface> $handlers
*/
public function __construct(
private array $handlers = []
) {}
/**
* Register a handler with a name (immutable)
*
* @return self New instance with handler registered
*/
public function register(string $name, ErrorHandlerInterface $handler): self
{
$newHandlers = [...$this->handlers];
$newHandlers[$name] = $handler;
return new self($newHandlers);
}
/**
* Get a handler by name
*/
public function get(string $name): ?ErrorHandlerInterface
{
return $this->handlers[$name] ?? null;
}
/**
* Check if a handler is registered
*/
public function has(string $name): bool
{
return isset($this->handlers[$name]);
}
/**
* Get all registered handlers
*
* @return array<string, ErrorHandlerInterface>
*/
public function all(): array
{
return $this->handlers;
}
/**
* Remove a handler by name (immutable)
*
* @return self New instance without the specified handler
*/
public function unregister(string $name): self
{
$newHandlers = $this->handlers;
unset($newHandlers[$name]);
return new self($newHandlers);
}
/**
* Clear all handlers (immutable)
*
* @return self New instance with no handlers
*/
public function clear(): self
{
return new self([]);
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Framework\ErrorHandling;
use App\Framework\ErrorHandling\Handlers\HandlerResult;
/**
* Result of processing an exception through the error handler chain
*/
final readonly class ErrorHandlingResult
{
/**
* @param bool $handled Whether the exception was handled by any handler
* @param HandlerResult[] $results Results from all handlers that processed the exception
* @param \Throwable $exception The original exception
*/
public function __construct(
public bool $handled,
public array $results,
public \Throwable $exception
) {}
/**
* Get the first handler result (usually the most important)
*/
public function getFirstResult(): ?HandlerResult
{
return $this->results[0] ?? null;
}
/**
* Get all handler messages
*
* @return string[]
*/
public function getMessages(): array
{
return array_map(
fn(HandlerResult $result) => $result->message,
$this->results
);
}
/**
* Get combined data from all handlers
*/
public function getCombinedData(): array
{
return array_merge(
...array_map(
fn(HandlerResult $result) => $result->data,
$this->results
)
);
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Framework\ErrorHandling\Handlers;
use App\Framework\Database\Exception\DatabaseException;
use App\Framework\Logging\Logger;
use App\Framework\Logging\ValueObjects\LogContext;
/**
* Handler for database-related errors
*
* Priority: HIGH - Database errors need immediate attention
*/
final readonly class DatabaseErrorHandler implements ErrorHandlerInterface
{
public function __construct(
private Logger $logger
) {}
public function canHandle(\Throwable $exception): bool
{
return $exception instanceof DatabaseException
|| $exception instanceof \PDOException;
}
public function handle(\Throwable $exception): HandlerResult
{
// Log database error with context
$this->logger->error('Database error occurred', LogContext::withData([
'exception_class' => get_class($exception),
'message' => $exception->getMessage(),
'code' => $exception->getCode()
]));
return HandlerResult::create(
handled: true,
message: 'A database error occurred',
data: [
'error_type' => 'database',
'retry_after' => 60 // Suggest retry after 60 seconds
],
statusCode: 500
);
}
public function getName(): string
{
return 'database_error_handler';
}
public function getPriority(): ErrorHandlerPriority
{
return ErrorHandlerPriority::HIGH;
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Framework\ErrorHandling\Handlers;
/**
* Interface for specialized error handlers in the error handling chain
*/
interface ErrorHandlerInterface
{
/**
* Check if this handler can handle the given exception
*/
public function canHandle(\Throwable $exception): bool;
/**
* Handle the exception and return a result
*/
public function handle(\Throwable $exception): HandlerResult;
/**
* Get handler name for logging/debugging
*/
public function getName(): string;
/**
* Get handler priority
*/
public function getPriority(): ErrorHandlerPriority;
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Framework\ErrorHandling\Handlers;
/**
* Priority levels for error handlers
*
* Handlers with higher priority execute first in the chain
*/
enum ErrorHandlerPriority: int
{
/**
* Critical handlers (Security, Authentication)
* Execute first to prevent security incidents
*/
case CRITICAL = 1000;
/**
* High priority handlers (Database, Validation)
* Execute early for data integrity
*/
case HIGH = 750;
/**
* Normal priority handlers (HTTP, Business Logic)
* Standard application errors
*/
case NORMAL = 500;
/**
* Low priority handlers (Logging, Monitoring)
* Non-critical error handling
*/
case LOW = 250;
/**
* Lowest priority handlers (Fallback)
* Catch-all handlers that execute last
*/
case LOWEST = 100;
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Framework\ErrorHandling\Handlers;
use App\Framework\Logging\Logger;
use App\Framework\Logging\ValueObjects\LogContext;
/**
* Fallback handler for all unhandled exceptions
*
* Priority: LOWEST - Catches everything that other handlers missed
*/
final readonly class FallbackErrorHandler implements ErrorHandlerInterface
{
public function __construct(
private Logger $logger
) {}
public function canHandle(\Throwable $exception): bool
{
return true; // Handles everything
}
public function handle(\Throwable $exception): HandlerResult
{
// Log unhandled exception with full context
$this->logger->error('Unhandled exception', LogContext::withData([
'exception_class' => get_class($exception),
'message' => $exception->getMessage(),
'file' => $exception->getFile(),
'line' => $exception->getLine(),
'trace' => $exception->getTraceAsString()
]));
return HandlerResult::create(
handled: true,
message: 'An unexpected error occurred',
data: [
'error_type' => 'unhandled',
'exception_class' => get_class($exception)
],
isFinal: true, // Stop chain - this is the last resort
statusCode: 500
);
}
public function getName(): string
{
return 'fallback_error_handler';
}
public function getPriority(): ErrorHandlerPriority
{
return ErrorHandlerPriority::LOWEST;
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Framework\ErrorHandling\Handlers;
/**
* Value Object representing a registered error handler with its priority.
*
* Replaces primitive array{handler: ErrorHandlerInterface, priority: int} pattern
* with type-safe, immutable Value Object following framework principles.
*/
final readonly class HandlerRegistration
{
public function __construct(
public ErrorHandlerInterface $handler,
public ErrorHandlerPriority $priority
) {}
/**
* Check if this handler can handle the given exception.
*/
public function canHandle(\Throwable $exception): bool
{
return $this->handler->canHandle($exception);
}
/**
* Execute the handler with the given exception.
*/
public function handle(\Throwable $exception): HandlerResult
{
return $this->handler->handle($exception);
}
/**
* Get handler name for debugging/logging.
*/
public function getHandlerName(): string
{
return $this->handler->getName();
}
/**
* Compare priority with another registration for sorting.
*/
public function comparePriority(self $other): int
{
// Higher priority value = execute first (descending sort)
return $other->priority->value <=> $this->priority->value;
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Framework\ErrorHandling\Handlers;
/**
* Result returned by error handlers after processing an exception
*/
final readonly class HandlerResult
{
public function __construct(
public bool $handled,
public string $message,
public array $data = [],
public bool $isFinal = false,
public ?int $statusCode = null
) {}
/**
* Factory method for creating handler results
*/
public static function create(
bool $handled,
string $message,
array $data = [],
bool $isFinal = false,
?int $statusCode = null
): self {
return new self($handled, $message, $data, $isFinal, $statusCode);
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Framework\ErrorHandling\Handlers;
use App\Framework\Http\Exception\HttpException;
/**
* Handler for HTTP-specific errors
*
* Priority: NORMAL - Standard HTTP error handling
*/
final readonly class HttpErrorHandler implements ErrorHandlerInterface
{
public function canHandle(\Throwable $exception): bool
{
return $exception instanceof HttpException;
}
public function handle(\Throwable $exception): HandlerResult
{
assert($exception instanceof HttpException);
return HandlerResult::create(
handled: true,
message: $exception->getMessage(),
data: [
'error_type' => 'http',
'headers' => $exception->headers ?? []
],
statusCode: $exception->statusCode
);
}
public function getName(): string
{
return 'http_error_handler';
}
public function getPriority(): ErrorHandlerPriority
{
return ErrorHandlerPriority::NORMAL;
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Framework\ErrorHandling\Handlers;
use App\Framework\Exception\ValidationException;
/**
* Handler for validation errors
*
* Priority: CRITICAL - Validates before any other processing
*/
final readonly class ValidationErrorHandler implements ErrorHandlerInterface
{
public function canHandle(\Throwable $exception): bool
{
return $exception instanceof ValidationException;
}
public function handle(\Throwable $exception): HandlerResult
{
assert($exception instanceof ValidationException);
return HandlerResult::create(
handled: true,
message: 'Validation failed',
data: [
'errors' => $exception->getErrors(),
'validation_context' => $exception->context->data
],
isFinal: true, // Stop handler chain for validation errors
statusCode: 422
);
}
public function getName(): string
{
return 'validation_error_handler';
}
public function getPriority(): ErrorHandlerPriority
{
return ErrorHandlerPriority::CRITICAL;
}
}

View File

@@ -8,6 +8,9 @@ use App\Framework\Exception\ErrorHandlerContext;
use App\Framework\Exception\SecurityException;
use App\Framework\Http\MiddlewareContext;
use App\Framework\Logging\Logger;
use App\Framework\Logging\Processors\SecurityEventProcessor;
use App\Framework\Logging\ValueObjects\LogContext;
use App\Framework\Logging\ValueObjects\SecurityContext;
use Throwable;
/**
@@ -16,7 +19,8 @@ use Throwable;
final readonly class SecurityEventHandler
{
public function __construct(
private ?SecurityEventLogger $securityLogger,
private Logger $logger,
private SecurityEventProcessor $processor,
private ?SecurityAlertManager $alertManager = null
) {
}
@@ -28,21 +32,78 @@ final readonly class SecurityEventHandler
SecurityException $exception,
?MiddlewareContext $context = null
): void {
// Skip if no logger available
if ($this->securityLogger === null) {
return;
}
try {
// Erstelle ErrorHandlerContext für OWASP-Format
$errorHandlerContext = $this->createErrorHandlerContext($exception, $context);
// Extract SecurityEvent from Exception
$securityEvent = $exception->getSecurityEvent();
// Führe Security-Logging durch
$this->securityLogger->logSecurityEvent($exception, $errorHandlerContext);
// Create SecurityContext based on event category
$securityContext = match ($securityEvent->getCategory()) {
'authentication' => SecurityContext::forAuthentication(
eventId: $securityEvent->getEventIdentifier(),
description: $securityEvent->getDescription(),
level: $securityEvent->getLogLevel(),
requiresAlert: $exception->requiresAlert(),
eventData: $securityEvent->toArray()
),
'authorization' => SecurityContext::forAuthorization(
eventId: $securityEvent->getEventIdentifier(),
description: $securityEvent->getDescription(),
level: $securityEvent->getLogLevel(),
requiresAlert: $exception->requiresAlert(),
eventData: $securityEvent->toArray()
),
'input_validation' => SecurityContext::forInputValidation(
eventId: $securityEvent->getEventIdentifier(),
description: $securityEvent->getDescription(),
level: $securityEvent->getLogLevel(),
requiresAlert: $exception->requiresAlert(),
eventData: $securityEvent->toArray()
),
'session' => SecurityContext::forSession(
eventId: $securityEvent->getEventIdentifier(),
description: $securityEvent->getDescription(),
level: $securityEvent->getLogLevel(),
requiresAlert: $exception->requiresAlert(),
eventData: $securityEvent->toArray()
),
'intrusion_detection' => SecurityContext::forIntrusion(
eventId: $securityEvent->getEventIdentifier(),
description: $securityEvent->getDescription(),
level: $securityEvent->getLogLevel(),
requiresAlert: $exception->requiresAlert(),
eventData: $securityEvent->toArray()
),
default => new SecurityContext(
eventId: $securityEvent->getEventIdentifier(),
level: $securityEvent->getLogLevel(),
description: $securityEvent->getDescription(),
category: $securityEvent->getCategory(),
requiresAlert: $exception->requiresAlert(),
eventData: $securityEvent->toArray()
),
};
// Add request info from MiddlewareContext if available
if ($context !== null) {
$securityContext = $securityContext->withRequestInfo(
sourceIp: (string) $context->request->server->getClientIp(),
userAgent: $context->request->server->getUserAgent()
);
}
// Map SecurityLogLevel to Framework LogLevel
$logLevel = $this->processor->mapSecurityLevelToLogLevel($securityEvent->getLogLevel());
// Log directly via Logger with SecurityContext
$this->logger->log(
$logLevel,
$securityEvent->getDescription(),
LogContext::empty()->withSecurityContext($securityContext)
);
// Sende Alert falls erforderlich
if ($exception->requiresAlert()) {
$this->handleSecurityAlert($exception, $errorHandlerContext);
$this->handleSecurityAlert($exception, $securityContext);
}
} catch (Throwable $loggingError) {
@@ -72,13 +133,17 @@ final readonly class SecurityEventHandler
*/
private function handleSecurityAlert(
SecurityException $exception,
ErrorHandlerContext $context
SecurityContext $securityContext
): void {
if ($this->alertManager) {
$this->alertManager->sendAlert($exception, $context);
} elseif ($this->securityLogger !== null) {
// Fallback: Logge als kritisches Event
$this->securityLogger->logCriticalAlert($exception, $context);
$this->alertManager->sendAlert($exception, $securityContext);
} else {
// Fallback: Logge als kritisches Event direkt mit Logger
$this->logger->log(
\App\Framework\Logging\LogLevel::CRITICAL,
'Security Alert: ' . $exception->getSecurityEvent()->getDescription(),
LogContext::empty()->withSecurityContext($securityContext)
);
}
}
@@ -117,41 +182,19 @@ final readonly class SecurityEventHandler
}
}
/**
* Erstellt ErrorHandlerContext aus Exception und MiddlewareContext
*/
private function createErrorHandlerContext(
SecurityException $exception,
?MiddlewareContext $context = null
): ErrorHandlerContext {
// Extrahiere Metadata aus dem MiddlewareContext
$metadata = [];
if ($context) {
$metadata = [
'request_id' => $context->request->id->toString(),
'middleware_context' => true,
];
}
// Verwende die bestehende fromException Methode mit korrekten Metadata
return ErrorHandlerContext::fromException($exception, $metadata);
}
/**
* Factory-Methode mit Standard-Konfiguration
*/
public static function createDefault(?Logger $logger = null, ?\App\Framework\Config\AppConfig $appConfig = null): self
public static function createDefault(?Logger $logger = null): self
{
// If AppConfig not provided, we can't create SecurityEventLogger properly
// This is a temporary solution - ideally AppConfig should always be provided
if ($logger === null || $appConfig === null) {
// Return a minimal handler without SecurityEventLogger
return new self(null, null);
// If Logger not provided, cannot create handler
if ($logger === null) {
throw new \InvalidArgumentException('Logger is required for SecurityEventHandler');
}
$securityLogger = new SecurityEventLogger($logger, $appConfig);
$processor = new SecurityEventProcessor();
$alertManager = null; // Kann später konfiguriert werden
return new self($securityLogger, $alertManager);
return new self($logger, $processor, $alertManager);
}
}

View File

@@ -13,27 +13,33 @@ use Traversable;
/**
* Stacktrace-Klasse zum Aufbereiten und Darstellen von Exception-Traces
* Implementiert ArrayAccess, IteratorAggregate und Countable für einfachen Zugriff in Templates
*
* Immutable: All properties are readonly, items array is built during construction
*/
final class StackTrace implements ArrayAccess, IteratorAggregate, Countable
final readonly class StackTrace implements ArrayAccess, IteratorAggregate, Countable
{
/** @var TraceItem[] */
public private(set) array $items = [];
public array $items;
/**
* Erstellt ein neues StackTrace-Objekt aus einer Exception
*/
public function __construct(private readonly \Throwable $exception)
public function __construct(private \Throwable $exception)
{
$this->processTrace($exception->getTrace());
$this->items = $this->processTrace($exception->getTrace());
}
/**
* Verarbeitet die rohen Trace-Daten in strukturierte TraceItems
*
* @return TraceItem[]
*/
private function processTrace(array $trace): void
private function processTrace(array $trace): array
{
$items = [];
// Füge den Ursprung der Exception hinzu (file/line aus Exception selbst)
$this->items[] = new TraceItem(
$items[] = new TraceItem(
file: $this->exception->getFile(),
line: $this->exception->getLine(),
function: null,
@@ -45,7 +51,7 @@ final class StackTrace implements ArrayAccess, IteratorAggregate, Countable
// Verarbeite den Rest des Stacktraces
foreach ($trace as $index => $frame) {
$this->items[] = new TraceItem(
$items[] = new TraceItem(
file: $frame['file'] ?? '(internal function)',
line: $frame['line'] ?? 0,
function: $frame['function'] ?? null,
@@ -56,7 +62,7 @@ final class StackTrace implements ArrayAccess, IteratorAggregate, Countable
);
}
$this->items = array_reverse($this->items);
return array_reverse($items);
}
/**

View File

@@ -0,0 +1,571 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ $errorClass }}: {{ $errorMessage }}</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: #1a202c;
color: #e2e8f0;
line-height: 1.6;
padding: 20px;
}
.debug-container {
max-width: 1400px;
margin: 0 auto;
}
.error-header {
background: linear-gradient(135deg, #dc2626 0%, #991b1b 100%);
border-radius: 12px;
padding: 32px;
margin-bottom: 24px;
box-shadow: 0 4px 20px rgba(220, 38, 38, 0.3);
position: relative;
overflow: hidden;
}
.error-header::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, transparent 100%);
pointer-events: none;
}
.error-class {
font-size: 28px;
font-weight: 700;
color: white;
margin-bottom: 12px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
position: relative;
}
.error-message {
font-size: 18px;
color: #fef3c7;
margin-bottom: 16px;
font-weight: 500;
position: relative;
}
.error-location {
font-family: 'Fira Code', 'Courier New', Courier, monospace;
font-size: 14px;
color: #fecaca;
background: rgba(0, 0, 0, 0.3);
padding: 12px 16px;
border-radius: 8px;
border-left: 4px solid #fbbf24;
position: relative;
}
.error-location strong {
color: #fbbf24;
font-weight: 600;
}
.section {
background: #2d3748;
border-radius: 12px;
padding: 24px;
margin-bottom: 20px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
}
.section-title {
font-size: 18px;
font-weight: 600;
color: #f7fafc;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 2px solid #4a5568;
display: flex;
justify-content: space-between;
align-items: center;
}
.section-title.collapsible {
cursor: pointer;
user-select: none;
}
.section-title.collapsible:hover {
color: #667eea;
}
.collapse-icon {
font-size: 20px;
transition: transform 0.3s;
color: #a0aec0;
}
.section-title.collapsed .collapse-icon {
transform: rotate(-90deg);
}
.section-content {
max-height: 2000px;
overflow: hidden;
transition: max-height 0.3s ease-out, opacity 0.3s ease-out;
opacity: 1;
}
.section-content.collapsed {
max-height: 0;
opacity: 0;
}
.meta-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 16px;
}
.meta-item {
background: #1a202c;
padding: 12px;
border-radius: 6px;
border-left: 3px solid #667eea;
}
.meta-label {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #a0aec0;
margin-bottom: 4px;
}
.meta-value {
font-family: 'Courier New', Courier, monospace;
color: #e2e8f0;
font-size: 14px;
word-break: break-all;
}
.code-preview {
position: relative;
margin-bottom: 20px;
}
/* Stelle sicher, dass Highlighter-Output nicht überschrieben wird */
.code-preview > * {
margin: 0;
}
/* Fallback für Code-Darstellung wenn Highlighter fehlt */
.code-preview pre {
background: #2b2b2b;
color: #a9b7c6;
padding: 16px;
border-radius: 8px;
overflow-x: auto;
font-family: 'Fira Code', 'Consolas', monospace;
font-size: 14px;
line-height: 1.5;
white-space: pre;
tab-size: 4;
}
.code-preview code {
font-family: 'Fira Code', 'Consolas', monospace;
white-space: pre;
}
/* Copy Button für Code */
.copy-button {
position: absolute;
top: 12px;
right: 12px;
background: #4a5568;
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
z-index: 10;
}
.copy-button:hover {
background: #667eea;
transform: translateY(-2px);
}
.copy-button:active {
transform: translateY(0);
}
.copy-button.copied {
background: #48bb78;
}
.copy-button.copied::after {
content: ' ✓';
}
.trace-item {
background: #1a202c;
padding: 16px;
margin-bottom: 8px;
border-radius: 6px;
border-left: 3px solid #4a5568;
transition: all 0.2s;
}
.trace-item:hover {
background: #283141;
border-left-color: #667eea;
}
.trace-item.origin {
border-left-color: #dc2626;
border-left-width: 4px;
background: linear-gradient(90deg, rgba(220, 38, 38, 0.15) 0%, #2d1f1f 100%);
box-shadow: 0 0 20px rgba(220, 38, 38, 0.2);
}
.trace-item.origin::before {
content: '⚠️';
position: absolute;
left: -12px;
top: 50%;
transform: translateY(-50%);
font-size: 20px;
}
.trace-index {
display: inline-block;
background: #4a5568;
color: white;
padding: 4px 10px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
margin-right: 8px;
}
.trace-item.origin .trace-index {
background: linear-gradient(135deg, #dc2626 0%, #991b1b 100%);
box-shadow: 0 2px 8px rgba(220, 38, 38, 0.3);
}
.trace-function {
font-family: 'Courier New', Courier, monospace;
color: #9ae6b4;
font-size: 14px;
margin-bottom: 4px;
}
.trace-location {
font-family: 'Courier New', Courier, monospace;
color: #90cdf4;
font-size: 13px;
}
.trace-filter {
margin-bottom: 16px;
display: flex;
gap: 12px;
align-items: center;
}
.trace-filter input {
flex: 1;
background: #1a202c;
border: 2px solid #4a5568;
color: #e2e8f0;
padding: 10px 16px;
border-radius: 8px;
font-size: 14px;
font-family: 'Courier New', Courier, monospace;
transition: border-color 0.2s;
}
.trace-filter input:focus {
outline: none;
border-color: #667eea;
}
.trace-filter-label {
color: #a0aec0;
font-size: 14px;
font-weight: 500;
}
.trace-stats {
color: #a0aec0;
font-size: 13px;
padding: 8px 12px;
background: #1a202c;
border-radius: 6px;
}
.dependency-info {
background: #78350f;
border: 2px solid #fbbf24;
border-radius: 8px;
padding: 16px;
margin-bottom: 20px;
}
.dependency-info h3 {
color: #fbbf24;
margin-bottom: 8px;
}
@media (max-width: 768px) {
.meta-grid {
grid-template-columns: 1fr;
}
.error-header {
padding: 20px;
}
.section {
padding: 16px;
}
}
</style>
</head>
<body>
<div class="debug-container">
<!-- Error Header -->
<div class="error-header">
<div class="error-class">⚠️ {{ $errorClass }}</div>
<div class="error-message">{{ $errorMessage }}</div>
<div class="error-location">
<strong>📁 File:</strong> {{ $errorFile }}<br>
<strong>📍 Line:</strong> {{ $errorLine }}
</div>
</div>
<!-- Dependency Info (if available) -->
<div if="{{ $dependencyInfo }}">
{{ $dependencyInfo }}
</div>
<!-- Request Information -->
<div class="section">
<h2 class="section-title collapsible collapsed" onclick="toggleSection('request-info')">
<span>📋 Request Information</span>
<span class="collapse-icon"></span>
</h2>
<div class="section-content collapsed" id="request-info-content">
<div class="meta-grid">
<div class="meta-item">
<div class="meta-label">Request ID</div>
<div class="meta-value">{{ $requestId }}</div>
</div>
<div class="meta-item">
<div class="meta-label">HTTP Status</div>
<div class="meta-value">{{ $httpStatus }}</div>
</div>
<div class="meta-item">
<div class="meta-label">Method</div>
<div class="meta-value">{{ $requestMethod }}</div>
</div>
<div class="meta-item">
<div class="meta-label">URI</div>
<div class="meta-value">{{ $requestUri }}</div>
</div>
<div class="meta-item">
<div class="meta-label">Client IP</div>
<div class="meta-value">{{ $clientIp }}</div>
</div>
<div class="meta-item">
<div class="meta-label">Timestamp</div>
<div class="meta-value">{{ $timestamp }}</div>
</div>
</div>
</div>
</div>
<!-- Code Preview -->
<div class="section">
<h2 class="section-title">💻 Code Context</h2>
<div class="code-preview" id="code-container">
<button class="copy-button" onclick="copyCode()" id="copy-code-btn">Copy Code</button>
{{ $code }}
</div>
</div>
<!-- Stack Trace -->
<div class="section" if="{{ $traceCount > 0 }}">
<h2 class="section-title collapsible" onclick="toggleSection('stack-trace')">
<span>📚 Stack Trace (<span id="trace-count">{{ $traceCount }}</span> frames)</span>
<span class="collapse-icon"></span>
</h2>
<div class="section-content" id="stack-trace-content">
<div class="trace-filter">
<span class="trace-filter-label">🔍 Filter:</span>
<input
type="text"
id="trace-filter"
placeholder="Search by class, method, or file path..."
oninput="filterStackTrace()"
>
<button class="copy-button" onclick="copyStackTrace()" id="copy-trace-btn" style="position: static; margin: 0;">
Copy Trace
</button>
</div>
<div foreach="$trace as $item">
<div class="trace-item" if="{{ $item->isOrigin }}">
<span class="trace-index">#{{ $item->index }}</span>
<div class="trace-function">🎯 {{ $item->function }}</div>
<div class="trace-location">{{ $item->file }}:{{ $item->line }}</div>
</div>
<div class="trace-item" if="!{{ $item->isOrigin }}">
<span class="trace-index">#{{ $item->index }}</span>
<div class="trace-function">{{ $item->function }}</div>
<div class="trace-location">{{ $item->file }}:{{ $item->line }}</div>
</div>
</div>
</div>
</div>
<!-- System Information -->
<div class="section">
<h2 class="section-title collapsible collapsed" onclick="toggleSection('system-info')">
<span>⚙️ System Information</span>
<span class="collapse-icon"></span>
</h2>
<div class="section-content collapsed" id="system-info-content">
<div class="meta-grid">
<div class="meta-item">
<div class="meta-label">Environment</div>
<div class="meta-value">{{ $environment }}</div>
</div>
<div class="meta-item">
<div class="meta-label">Debug Mode</div>
<div class="meta-value">{{ $debugMode }}</div>
</div>
<div class="meta-item">
<div class="meta-label">PHP Version</div>
<div class="meta-value">{{ $phpVersion }}</div>
</div>
<div class="meta-item">
<div class="meta-label">Memory Usage</div>
<div class="meta-value">{{ $memory }}</div>
</div>
<div class="meta-item">
<div class="meta-label">Execution Time</div>
<div class="meta-value">{{ $executionTime }}</div>
</div>
<div class="meta-item">
<div class="meta-label">Memory Limit</div>
<div class="meta-value">{{ $memoryLimit }}</div>
</div>
</div>
</div>
</div>
</div>
<script>
// Toggle collapsible sections
function toggleSection(sectionId) {
const content = document.getElementById(sectionId + '-content');
const title = content.previousElementSibling;
content.classList.toggle('collapsed');
title.classList.toggle('collapsed');
}
// Copy code to clipboard
function copyCode() {
const codeContainer = document.getElementById('code-container');
const codeElement = codeContainer.querySelector('pre') || codeContainer.querySelector('code');
if (!codeElement) {
return;
}
const text = codeElement.textContent || codeElement.innerText;
navigator.clipboard.writeText(text).then(() => {
const button = document.getElementById('copy-code-btn');
button.classList.add('copied');
button.textContent = 'Copied!';
setTimeout(() => {
button.classList.remove('copied');
button.textContent = 'Copy Code';
}, 2000);
}).catch(err => {
console.error('Failed to copy:', err);
});
}
// Copy stack trace
function copyStackTrace() {
const traceContainer = document.getElementById('stack-trace-content');
const traceItems = traceContainer.querySelectorAll('.trace-item');
let text = 'Stack Trace:\n\n';
traceItems.forEach(item => {
const func = item.querySelector('.trace-function').textContent;
const location = item.querySelector('.trace-location').textContent;
text += `${func}\n at ${location}\n\n`;
});
navigator.clipboard.writeText(text).then(() => {
const button = document.getElementById('copy-trace-btn');
button.classList.add('copied');
button.textContent = 'Copied!';
setTimeout(() => {
button.classList.remove('copied');
button.textContent = 'Copy Trace';
}, 2000);
}).catch(err => {
console.error('Failed to copy:', err);
});
}
// Filter stack trace
function filterStackTrace() {
const input = document.getElementById('trace-filter');
const filter = input.value.toLowerCase();
const items = document.querySelectorAll('.trace-item');
let visibleCount = 0;
items.forEach(item => {
const text = item.textContent.toLowerCase();
if (text.includes(filter)) {
item.style.display = '';
visibleCount++;
} else {
item.style.display = 'none';
}
});
// Update trace count
const traceCount = document.getElementById('trace-count');
if (traceCount) {
if (filter) {
traceCount.textContent = `${visibleCount}/${items.length}`;
} else {
traceCount.textContent = items.length;
}
}
}
</script>
</body>
</html>

View File

@@ -0,0 +1,141 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Error {{ $httpStatus }}</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: #1a202c;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
color: #e2e8f0;
}
.error-container {
background: #2d3748;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
max-width: 600px;
width: 100%;
padding: 48px 32px;
text-align: center;
}
.status-code {
font-size: 96px;
font-weight: 700;
color: #667eea;
margin-bottom: 24px;
line-height: 1;
}
.error-title {
font-size: 28px;
margin-bottom: 16px;
color: #f7fafc;
font-weight: 600;
}
.error-message {
color: #cbd5e0;
line-height: 1.6;
margin-bottom: 32px;
font-size: 16px;
}
.error-meta {
background: #1a202c;
padding: 20px;
border-radius: 12px;
border: 1px solid #4a5568;
}
.meta-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid #4a5568;
}
.meta-item:last-child {
border-bottom: none;
}
.meta-label {
font-weight: 600;
color: #a0aec0;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.meta-value {
font-family: 'Courier New', Courier, monospace;
color: #e2e8f0;
font-size: 13px;
word-break: break-all;
max-width: 60%;
text-align: right;
}
@media (max-width: 600px) {
.error-container {
padding: 32px 24px;
}
.status-code {
font-size: 72px;
}
.error-title {
font-size: 22px;
}
.meta-item {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.meta-value {
max-width: 100%;
text-align: left;
}
}
</style>
</head>
<body>
<div class="error-container">
<div class="status-code">{{ $httpStatus }}</div>
<h1 class="error-title">{{ $error->message }}</h1>
<p class="error-message">
Bitte versuchen Sie es später erneut oder kontaktieren Sie den Support, falls das Problem bestehen bleibt.
</p>
<div class="error-meta">
<div class="meta-item">
<span class="meta-label">Request ID</span>
<span class="meta-value">{{ $requestId }}</span>
</div>
<div class="meta-item">
<span class="meta-label">Zeitpunkt</span>
<span class="meta-value">{{ $timestamp }}</span>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,118 @@
<layout src="main" />
<div class="error-container">
<div class="status-code">{{ $httpStatus }}</div>
<h1 class="error-title">{{ $error->message }}</h1>
<p class="error-message">
Bitte versuchen Sie es später erneut oder kontaktieren Sie den Support, falls das Problem bestehen bleibt.
</p>
<div class="error-meta">
<div class="meta-item">
<span class="meta-label">Request ID</span>
<span class="meta-value">{{ $requestId }}</span>
</div>
<div class="meta-item">
<span class="meta-label">Zeitpunkt</span>
<span class="meta-value">{{ $timestamp }}</span>
</div>
</div>
</div>
<style>
.error-container {
max-width: 600px;
margin: 80px auto;
padding: 48px 32px;
text-align: center;
background: #2d3748;
border-radius: 16px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.status-code {
font-size: 96px;
font-weight: 700;
color: #667eea;
margin-bottom: 24px;
line-height: 1;
}
.error-title {
font-size: 28px;
margin-bottom: 16px;
color: #f7fafc;
font-weight: 600;
}
.error-message {
color: #cbd5e0;
line-height: 1.6;
margin-bottom: 32px;
font-size: 16px;
}
.error-meta {
background: #1a202c;
padding: 20px;
border-radius: 12px;
border: 1px solid #4a5568;
}
.meta-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid #4a5568;
}
.meta-item:last-child {
border-bottom: none;
}
.meta-label {
font-weight: 600;
color: #a0aec0;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.meta-value {
font-family: 'Courier New', Courier, monospace;
color: #e2e8f0;
font-size: 13px;
word-break: break-all;
max-width: 60%;
text-align: right;
}
@media (max-width: 600px) {
.error-container {
margin: 40px 20px;
padding: 32px 24px;
}
.status-code {
font-size: 72px;
}
.error-title {
font-size: 22px;
}
.meta-item {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.meta-value {
max-width: 100%;
text-align: left;
}
}
</style>

View File

@@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
namespace App\Framework\ErrorHandling\ValueObjects;
use App\Framework\ErrorHandling\ErrorSeverity;
/**
* Value Object representing error classification metadata.
*
* Replaces primitive array pattern in ErrorHandler::createExceptionMetadata()
* with type-safe, immutable Value Object following framework principles.
*
* This is distinct from ExceptionMetadata (behavior control) - this handles
* error classification and HTTP response metadata.
*/
final readonly class ErrorMetadata
{
/**
* @param string $exceptionClass Fully qualified exception class name
* @param ErrorSeverity $errorLevel Error severity level
* @param int $httpStatus HTTP status code for response
* @param string|null $errorCode Optional error code from FrameworkException
* @param string|null $errorCategory Optional error category (e.g., 'AUTH', 'VAL')
* @param string|null $errorSeverity Optional severity from ErrorCode
* @param bool|null $isRecoverable Whether error is recoverable
* @param string|null $recoveryHint Optional hint for recovery (debug mode)
* @param array<string, string> $additionalHeaders HTTP headers to add to response
*/
public function __construct(
public string $exceptionClass,
public ErrorSeverity $errorLevel,
public int $httpStatus,
public ?string $errorCode = null,
public ?string $errorCategory = null,
public ?string $errorSeverity = null,
public ?bool $isRecoverable = null,
public ?string $recoveryHint = null,
public array $additionalHeaders = []
) {}
/**
* Create basic metadata for non-FrameworkException errors.
*/
public static function basic(
string $exceptionClass,
ErrorSeverity $errorLevel,
int $httpStatus
): self {
return new self(
exceptionClass: $exceptionClass,
errorLevel: $errorLevel,
httpStatus: $httpStatus
);
}
/**
* Create enhanced metadata for FrameworkException errors.
*/
public static function enhanced(
string $exceptionClass,
ErrorSeverity $errorLevel,
int $httpStatus,
string $errorCode,
string $errorCategory,
string $errorSeverity,
bool $isRecoverable,
?string $recoveryHint = null
): self {
return new self(
exceptionClass: $exceptionClass,
errorLevel: $errorLevel,
httpStatus: $httpStatus,
errorCode: $errorCode,
errorCategory: $errorCategory,
errorSeverity: $errorSeverity,
isRecoverable: $isRecoverable,
recoveryHint: $recoveryHint
);
}
/**
* Add additional HTTP headers (immutable).
*/
public function withAdditionalHeaders(array $headers): self
{
return new self(
exceptionClass: $this->exceptionClass,
errorLevel: $this->errorLevel,
httpStatus: $this->httpStatus,
errorCode: $this->errorCode,
errorCategory: $this->errorCategory,
errorSeverity: $this->errorSeverity,
isRecoverable: $this->isRecoverable,
recoveryHint: $this->recoveryHint,
additionalHeaders: array_merge($this->additionalHeaders, $headers)
);
}
/**
* Check if this is enhanced metadata (from FrameworkException).
*/
public function isEnhanced(): bool
{
return $this->errorCode !== null;
}
/**
* Check if metadata has additional headers.
*/
public function hasAdditionalHeaders(): bool
{
return !empty($this->additionalHeaders);
}
/**
* Convert to array for legacy compatibility.
*
* @return array<string, mixed>
*/
public function toArray(): array
{
$array = [
'exception_class' => $this->exceptionClass,
'error_level' => $this->errorLevel->name,
'http_status' => $this->httpStatus,
];
// Add enhanced fields if present
if ($this->errorCode !== null) {
$array['error_code'] = $this->errorCode;
}
if ($this->errorCategory !== null) {
$array['error_category'] = $this->errorCategory;
}
if ($this->errorSeverity !== null) {
$array['error_severity'] = $this->errorSeverity;
}
if ($this->isRecoverable !== null) {
$array['is_recoverable'] = $this->isRecoverable;
}
if ($this->recoveryHint !== null) {
$array['recovery_hint'] = $this->recoveryHint;
}
if (!empty($this->additionalHeaders)) {
$array['additional_headers'] = $this->additionalHeaders;
}
return $array;
}
/**
* Create from array (for testing/migration).
*
* @param array<string, mixed> $data
*/
public static function fromArray(array $data): self
{
return new self(
exceptionClass: $data['exception_class'] ?? '',
errorLevel: ErrorSeverity::from($data['error_level'] ?? 'ERROR'),
httpStatus: $data['http_status'] ?? 500,
errorCode: $data['error_code'] ?? null,
errorCategory: $data['error_category'] ?? null,
errorSeverity: $data['error_severity'] ?? null,
isRecoverable: $data['is_recoverable'] ?? null,
recoveryHint: $data['recovery_hint'] ?? null,
additionalHeaders: $data['additional_headers'] ?? []
);
}
}

View File

@@ -18,15 +18,17 @@ use App\Framework\View\TemplateRenderer;
use Throwable;
/**
* Renderer für Fehlerseiten unter Verwendung des View-Systems
* Renderer für Fehlerseiten mit intelligenter Fallback-Kaskade
*
* Fallback-Strategie:
* - Production: production.view.php (mit Layout) → production-minimal.view.php → fallback.html
* - Debug: debug.view.php → fallback.html
*/
final readonly class ErrorTemplateRenderer implements ErrorViewRendererInterface
{
public function __construct(
private TemplateRenderer $renderer,
private AppConfig $appConfig,
private string $debugTemplate = 'enhanced-debug',
private string $productionTemplate = 'production'
private AppConfig $appConfig
) {
}
@@ -35,247 +37,236 @@ final readonly class ErrorTemplateRenderer implements ErrorViewRendererInterface
*/
public function renderFromHandlerContext(ErrorHandlerContext $context, bool $isDebug = false): string
{
$template = $isDebug ? $this->debugTemplate : $this->productionTemplate;
if ($isDebug) {
return $this->renderDebugError($context);
}
return $this->renderProductionError($context);
}
/**
* Production Error mit 3-Stufen Fallback-Kaskade
*
* Level 1: production.view.php (mit Layout - beste UX)
* Level 2: production-minimal.view.php (standalone - robuster)
* Level 3: fallback.html (statisch - immer verfügbar)
*/
private function renderProductionError(ErrorHandlerContext $context): string
{
// Level 1: Mit Layout (bevorzugt)
try {
// Sichere Datenaufbereitung für ErrorHandlerContext
$errorFile = $this->extractErrorFile($context);
$errorLine = $this->extractErrorLine($context);
$safeData = [
'errorClass' => $this->extractExceptionClass($context),
'errorMessage' => $this->extractErrorMessage($context),
'errorFile' => $errorFile,
'errorLine' => $errorLine,
'errorCode' => $context->metadata['http_status'] ?? 500,
'error' => [
'message' => $this->extractErrorMessage($context),
'operation' => $context->exception->operation ?? 'unknown',
'component' => $context->exception->component ?? 'Application',
'class' => $context->metadata['exception_class'] ?? 'Unknown',
],
'requestId' => $context->request->requestId ?? 'NO-REQUEST-ID',
'timestamp' => date('c'),
'level' => $context->metadata['error_level'] ?? 'ERROR',
'memory' => is_int($context->system->memoryUsage)
? Byte::fromBytes($context->system->memoryUsage)->toHumanReadable()
: ($context->system->memoryUsage ?? '0 B'),
'httpStatus' => $context->metadata['http_status'] ?? 500,
'clientIp' => $context->request->clientIp ?? 'Unknown',
'requestUri' => $context->request->requestUri ?? '/',
'requestMethod' => $context->request->requestMethod ?? 'GET',
'userAgent' => (string) ($context->request->userAgent ?? 'Unknown'),
'traceCount' => 0, // Will be updated below if trace is available
// Environment information
'environment' => $this->appConfig->type->value,
'debugMode' => $isDebug ? 'Enabled' : 'Disabled',
'phpVersion' => PHP_VERSION,
'frameworkVersion' => '1.0.0-dev',
// Performance information
'executionTime' => $context->system->executionTime !== null
? Duration::fromSeconds($context->system->executionTime)->toHumanReadable()
: 'N/A',
'memoryLimit' => ini_get('memory_limit') ?: 'Unknown',
// Server information
'serverSoftware' => $_SERVER['SERVER_SOFTWARE'] ?? 'Unknown',
'documentRoot' => $_SERVER['DOCUMENT_ROOT'] ?? '/var/www/html',
'serverName' => $_SERVER['SERVER_NAME'] ?? 'localhost',
// Immer Standard-Werte setzen
'dependencyInfo' => '',
'code' => RawHtml::from('<div style="padding: 20px; background: #f8f9fa; border-radius: 4px; color: #6c757d;">Code-Vorschau nicht verfügbar</div>'),
'trace' => [],
'traceCount' => 0,
];
// Security Event spezifische Daten
if ($context->exception->metadata['security_event'] ?? false) {
$safeData['securityEvent'] = [
'type' => $context->exception->metadata['attack_type'] ?? 'unknown',
'category' => $context->exception->metadata['category'] ?? 'security',
'requiresAlert' => $context->exception->metadata['requires_alert'] ?? false,
];
// In Production keine Security Details anzeigen
if (! $isDebug) {
$safeData['errorMessage'] = 'Security violation detected. Access denied.';
$safeData['error']['message'] = 'Security violation detected. Access denied.';
}
}
// Debug-spezifische Daten
if ($isDebug) {
$safeData['debug'] = [
'exception_data' => $context->exception->data,
'exception_debug' => $context->exception->debug,
'system_data' => $context->system->data ?? [],
'execution_time' => $context->system->executionTime ?? 0,
];
// Dependency Injection spezifische Informationen
if (isset($context->exception->data['dependencyChain'])) {
$chainDisplay = implode(' → ', $context->exception->data['dependencyChain']) . ' → ' . ($context->exception->data['class'] ?? 'Unknown');
$targetClass = $context->exception->data['class'] ?? 'Unknown';
$dependencyHtml = '<div class="dependency-info" style="margin-top: 20px; padding: 15px; background: #fff3cd; border-left: 4px solid #ffc107; border-radius: 4px;">' .
'<h3 style="margin: 0 0 10px 0; color: #856404;">🔄 Zyklische Abhängigkeit</h3>' .
'<p style="margin: 0 0 10px 0; font-family: monospace; font-size: 0.9rem; color: #856404;">' .
'<strong>Abhängigkeitskette:</strong><br>' .
htmlspecialchars($chainDisplay, ENT_QUOTES, 'UTF-8') .
'</p>' .
'<p style="margin: 0; font-size: 0.85rem; color: #856404;">' .
'Die Klasse <code>' . htmlspecialchars($targetClass, ENT_QUOTES, 'UTF-8') . '</code> kann nicht instanziiert werden, ' .
'da sie Teil einer zyklischen Abhängigkeit ist.' .
'</p>' .
'</div>';
$safeData['dependencyInfo'] = RawHtml::from($dependencyHtml);
} else {
// Standard-Fallback wenn keine Dependency-Info verfügbar
$safeData['dependencyInfo'] = '';
}
// Code-Highlighter für Debug-Template
$file = $context->exception->data['exception_file'] ?? null;
$line = $context->exception->data['exception_line'] ?? null;
if ($file && $line && is_numeric($line)) {
$errorLine = (int)$line;
$codeHtml = new FileHighlighter()($file, max(1, $errorLine - 10), 20, $errorLine);
$safeData['code'] = RawHtml::from($codeHtml);
} else {
$safeData['code'] = RawHtml::from('<div style="padding: 20px; background: #f8f9fa; border-radius: 4px; color: #6c757d;">Code-Vorschau nicht verfügbar</div>');
}
// Stack Trace für Debug-Template
// Prüfe zuerst, ob eine ursprüngliche Exception verfügbar ist
// Stack Trace mit Index hinzufügen
if (isset($context->exception->data['original_exception']) && $context->exception->data['original_exception'] instanceof \Throwable) {
// Verwende die ursprüngliche Exception für den Stack Trace
$originalException = $context->exception->data['original_exception'];
$stackTrace = new StackTrace($originalException);
$traceItems = $stackTrace->getItems();
// Index zu jedem Item hinzufügen
$indexedTraceItems = [];
foreach ($traceItems as $index => $item) {
$indexedTraceItems[] = [
'file' => $item->getRelativeFile(),
'line' => $item->line,
'function' => $item->class ? $item->class . $item->type . $item->function : $item->function,
'class' => $item->class,
'index' => $index,
'isOrigin' => $item->isExceptionOrigin,
];
}
$safeData['trace'] = $indexedTraceItems;
$safeData['traceCount'] = count($traceItems);
} elseif (isset($context->exception->data['previous_exception']) && $context->exception->data['previous_exception'] instanceof \Throwable) {
// Alternative: Prüfe auf previous_exception
$stackTrace = new StackTrace($context->exception->data['previous_exception']);
$traceItems = $stackTrace->getItems();
// Index zu jedem Item hinzufügen
$indexedTraceItems = [];
foreach ($traceItems as $index => $item) {
$indexedTraceItems[] = [
'file' => $item->getRelativeFile(),
'line' => $item->line,
'function' => $item->class ? $item->class . $item->type . $item->function : $item->function,
'class' => $item->class,
'index' => $index,
'isOrigin' => $item->isExceptionOrigin,
];
}
$safeData['trace'] = $indexedTraceItems;
$safeData['traceCount'] = count($traceItems);
} else {
// Fallback: Erstelle minimalen Trace aus verfügbaren Daten
$file = $context->exception->data['exception_file'] ?? null;
$line = $context->exception->data['exception_line'] ?? null;
if ($file && $line) {
$safeData['trace'] = [[
'file' => str_replace(dirname(__DIR__, 4), '', $file),
'line' => (int)$line,
'function' => 'Unknown',
'class' => null,
'index' => 0,
'isOrigin' => true,
]];
$safeData['traceCount'] = 1;
} else {
$safeData['trace'] = [];
$safeData['traceCount'] = 0;
}
}
}
// Sicherstellen, dass alle Template-Variablen definiert sind
if (! isset($safeData['dependencyInfo'])) {
$safeData['dependencyInfo'] = '';
}
if (! isset($safeData['code'])) {
$safeData['code'] = RawHtml::from('<div style="padding: 20px; background: #f8f9fa; border-radius: 4px; color: #6c757d;">Code-Vorschau nicht verfügbar</div>');
}
if (! isset($safeData['trace'])) {
$safeData['trace'] = [];
}
if (! isset($safeData['traceCount'])) {
$safeData['traceCount'] = 0;
}
$renderContext = new RenderContext(
template: $template,
metaData: new MetaData('', '', new OpenGraphTypeWebsite()),
data: $safeData
);
$renderedContent = $this->renderer->render($renderContext);
if ($renderedContent === "") {
throw new \Exception("Template Renderer returned empty string");
}
return $renderedContent;
return $this->renderTemplate('production', $context, isDebug: false);
} catch (Throwable $e) {
// Fallback falls das Template-Rendering fehlschlägt
return $this->createFallbackErrorPageFromHandlerContext($context, $isDebug, $e);
// Level 2: Ohne Layout (Fallback)
try {
return $this->renderTemplate('production-minimal', $context, isDebug: false);
} catch (Throwable $e) {
// Level 3: Statischer Fallback
return $this->renderStaticFallback($context);
}
}
}
/**
* Extrahiert Fehlermeldung aus ErrorHandlerContext
* Debug Error mit 2-Stufen Fallback-Kaskade
*
* Level 1: debug.view.php (standalone - maximaler Fokus)
* Level 2: fallback.html (statisch - immer verfügbar)
*/
private function renderDebugError(ErrorHandlerContext $context): string
{
// Level 1: Debug Template (standalone)
try {
return $this->renderTemplate('debug', $context, isDebug: true);
} catch (Throwable $e) {
// Level 2: Statischer Fallback
return $this->renderStaticFallback($context);
}
}
/**
* Rendert ein Error-Template mit vorbereiteten Daten
*/
private function renderTemplate(string $templateName, ErrorHandlerContext $context, bool $isDebug): string
{
$data = $this->prepareTemplateData($context, $isDebug);
$renderContext = new RenderContext(
template: $templateName,
metaData: new MetaData('', '', new OpenGraphTypeWebsite()),
data: $data
);
$rendered = $this->renderer->render($renderContext);
if ($rendered === '') {
throw new \RuntimeException("Template '{$templateName}' returned empty string");
}
return $rendered;
}
/**
* Bereitet Template-Daten aus ErrorHandlerContext vor
*/
private function prepareTemplateData(ErrorHandlerContext $context, bool $isDebug): array
{
$data = [
// Error Information
'errorClass' => $this->extractExceptionClass($context),
'errorMessage' => $this->extractErrorMessage($context),
'errorFile' => $this->extractErrorFile($context),
'errorLine' => $this->extractErrorLine($context),
'errorCode' => $context->metadata['http_status'] ?? 500,
'error' => (object) [
'message' => $this->extractErrorMessage($context),
'operation' => $context->exception->operation ?? 'unknown',
'component' => $context->exception->component ?? 'Application',
'class' => $context->metadata['exception_class'] ?? 'Unknown',
],
// Request Information
'requestId' => $context->request->requestId?->toString() ?? 'unknown',
'timestamp' => date('c'),
'httpStatus' => $context->metadata['http_status'] ?? 500,
'clientIp' => $context->request->clientIp ?? 'Unknown',
'requestUri' => $context->request->requestUri ?? '/',
'requestMethod' => $context->request->requestMethod ?? 'GET',
'userAgent' => (string) ($context->request->userAgent ?? 'Unknown'),
// System Information
'environment' => $this->appConfig->type->value,
'debugMode' => $isDebug ? 'Enabled' : 'Disabled',
'phpVersion' => PHP_VERSION,
'frameworkVersion' => '1.0.0-dev',
'memory' => is_int($context->system->memoryUsage)
? Byte::fromBytes($context->system->memoryUsage)->toHumanReadable()
: ($context->system->memoryUsage ?? '0 B'),
'memoryLimit' => ini_get('memory_limit') ?: 'Unknown',
'executionTime' => $context->system->executionTime !== null
? Duration::fromSeconds($context->system->executionTime)->toHumanReadable()
: 'N/A',
// Debug-only data
'dependencyInfo' => '',
'code' => RawHtml::from(''),
'trace' => [],
'traceCount' => 0,
];
// Security Event Handling
if ($context->exception->metadata['security_event'] ?? false) {
if (! $isDebug) {
$data['errorMessage'] = 'Security violation detected. Access denied.';
$data['error']->message = 'Security violation detected. Access denied.';
}
}
// Add Debug-specific data
if ($isDebug) {
$this->addDebugData($data, $context);
}
return $data;
}
/**
* Fügt Debug-spezifische Daten hinzu (Code Preview, Stack Trace, etc.)
*/
private function addDebugData(array &$data, ErrorHandlerContext $context): void
{
// Dependency Information
if (isset($context->exception->data['dependencyChain'])) {
$chain = implode(' → ', $context->exception->data['dependencyChain']);
$target = $context->exception->data['class'] ?? 'Unknown';
$html = sprintf(
'<div class="dependency-info">' .
'<h3>🔄 Zyklische Abhängigkeit</h3>' .
'<p><strong>Kette:</strong><br>%s → %s</p>' .
'<p>Die Klasse kann nicht instanziiert werden.</p>' .
'</div>',
htmlspecialchars($chain, ENT_QUOTES, 'UTF-8'),
htmlspecialchars($target, ENT_QUOTES, 'UTF-8')
);
$data['dependencyInfo'] = RawHtml::from($html);
}
// Code Preview
$file = $context->exception->data['exception_file'] ?? null;
$line = $context->exception->data['exception_line'] ?? null;
if ($file && $line && is_numeric($line)) {
$errorLine = (int) $line;
$codeHtml = (new FileHighlighter())($file, max(1, $errorLine - 10), 20, $errorLine);
$data['code'] = RawHtml::from($codeHtml);
}
// Stack Trace
$exception = $context->exception->data['original_exception'] ??
$context->exception->data['previous_exception'] ??
null;
if ($exception instanceof Throwable) {
$stackTrace = new StackTrace($exception);
$traceItems = $stackTrace->getItems();
$indexedTrace = [];
foreach ($traceItems as $index => $item) {
$indexedTrace[] = (object) [
'file' => $item->getRelativeFile(),
'line' => $item->line,
'function' => $item->class ? $item->class . $item->type . $item->function : $item->function,
'class' => $item->class,
'index' => $index,
'isOrigin' => $item->isExceptionOrigin,
];
}
$data['trace'] = $indexedTrace;
$data['traceCount'] = count($traceItems);
}
}
/**
* Statischer HTML-Fallback (Level 3 - kann NICHT fehlschlagen)
*/
private function renderStaticFallback(ErrorHandlerContext $context): string
{
$fallbackPath = __DIR__ . '/../fallback.html';
if (file_exists($fallbackPath)) {
$html = file_get_contents($fallbackPath);
return str_replace(
'{REQUEST_ID}',
htmlspecialchars($context->request->requestId?->toString() ?? 'unknown', ENT_QUOTES, 'UTF-8'),
$html
);
}
// Absolutes Notfall-Minimum
return '<!DOCTYPE html><html lang="de"><head><meta charset="UTF-8"><title>503 Service Unavailable</title></head><body><h1>503 Service Unavailable</h1></body></html>';
}
/**
* Extrahiert Fehlermeldung aus Context
*/
private function extractErrorMessage(ErrorHandlerContext $context): string
{
// Aus Exception-Daten extrahieren
if (isset($context->exception->data['exception_message'])) {
return $context->exception->data['exception_message'];
}
// Aus Exception-Daten (message)
if (isset($context->exception->data['message'])) {
return $context->exception->data['message'];
}
// Aus Exception-Daten (user_message)
if (isset($context->exception->data['user_message'])) {
return $context->exception->data['user_message'];
}
// Versuche die originale Exception zu verwenden
if (isset($context->exception->data['original_exception']) && $context->exception->data['original_exception'] instanceof \Throwable) {
if (isset($context->exception->data['original_exception']) && $context->exception->data['original_exception'] instanceof Throwable) {
return $context->exception->data['original_exception']->getMessage();
}
// Versuche previous_exception
if (isset($context->exception->data['previous_exception']) && $context->exception->data['previous_exception'] instanceof \Throwable) {
return $context->exception->data['previous_exception']->getMessage();
}
// Fallback: Operation und Component verwenden
$operation = $context->exception->operation ?? 'unknown_operation';
$component = $context->exception->component ?? 'Application';
@@ -283,190 +274,50 @@ final readonly class ErrorTemplateRenderer implements ErrorViewRendererInterface
}
/**
* Extrahiert die Exception-Klasse
* Extrahiert Exception-Klasse aus Context
*/
private function extractExceptionClass(ErrorHandlerContext $context): string
{
// Aus Metadata extrahieren
if (isset($context->metadata['exception_class'])) {
return $context->metadata['exception_class'];
}
// Versuche die originale Exception zu verwenden
if (isset($context->exception->data['original_exception']) && $context->exception->data['original_exception'] instanceof \Throwable) {
$originalException = $context->exception->data['original_exception'];
// Für FrameworkException: Zeige die FrameworkException-Klasse, nicht die PDO-Exception
// da DatabaseException die richtige Exception-Klasse ist
return get_class($originalException);
}
// Versuche previous_exception
if (isset($context->exception->data['previous_exception']) && $context->exception->data['previous_exception'] instanceof \Throwable) {
return get_class($context->exception->data['previous_exception']);
if (isset($context->exception->data['original_exception']) && $context->exception->data['original_exception'] instanceof Throwable) {
return get_class($context->exception->data['original_exception']);
}
return 'Unknown';
}
/**
* Extrahiert die Fehlerdatei
* Extrahiert Fehler-Datei aus Context
*/
private function extractErrorFile(ErrorHandlerContext $context): string
{
// 1. Priorität: Explizit gesetzte exception_file (für DatabaseException Fix)
if (isset($context->exception->data['exception_file']) && ! empty($context->exception->data['exception_file'])) {
return $context->exception->data['exception_file'];
}
// 2. Priorität: Versuche die originale Exception zu verwenden
if (isset($context->exception->data['original_exception']) && $context->exception->data['original_exception'] instanceof \Throwable) {
$originalException = $context->exception->data['original_exception'];
return $originalException->getFile();
}
// 3. Priorität: Versuche previous_exception
if (isset($context->exception->data['previous_exception']) && $context->exception->data['previous_exception'] instanceof \Throwable) {
return $context->exception->data['previous_exception']->getFile();
if (isset($context->exception->data['original_exception']) && $context->exception->data['original_exception'] instanceof Throwable) {
return $context->exception->data['original_exception']->getFile();
}
return 'Unknown';
}
/**
* Extrahiert die Fehlerzeile
* Extrahiert Fehler-Zeile aus Context
*/
private function extractErrorLine(ErrorHandlerContext $context): int
{
// 1. Priorität: Explizit gesetzte exception_line (für DatabaseException Fix)
if (isset($context->exception->data['exception_line']) && is_numeric($context->exception->data['exception_line'])) {
return (int) $context->exception->data['exception_line'];
}
// 2. Priorität: Versuche die originale Exception zu verwenden
if (isset($context->exception->data['original_exception']) && $context->exception->data['original_exception'] instanceof \Throwable) {
$originalException = $context->exception->data['original_exception'];
return $originalException->getLine();
}
// 3. Priorität: Versuche previous_exception
if (isset($context->exception->data['previous_exception']) && $context->exception->data['previous_exception'] instanceof \Throwable) {
return $context->exception->data['previous_exception']->getLine();
if (isset($context->exception->data['original_exception']) && $context->exception->data['original_exception'] instanceof Throwable) {
return $context->exception->data['original_exception']->getLine();
}
return 0;
}
/**
* Erstellt eine einfache Fehlerseite als Fallback für ErrorHandlerContext
*/
private function createFallbackErrorPageFromHandlerContext(ErrorHandlerContext $context, bool $isDebug, ?Throwable $renderException = null): string
{
$errorMessage = $this->extractErrorMessage($context);
$isSecurityEvent = $context->exception->metadata['security_event'] ?? false;
if ($isDebug) {
$content = sprintf(
'<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Anwendungsfehler</title>
<style>
body { font-family: sans-serif; line-height: 1.6; color: #333; max-width: 1200px; margin: 0 auto; padding: 20px; }
.error-container { background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 4px; padding: 20px; margin: 20px 0; }
.error-title { color: #dc3545; margin-top: 0; }
.error-meta { background: #e9ecef; padding: 10px; border-radius: 4px; font-size: 0.9em; margin: 10px 0; }
.error-trace { background: #f1f3f5; padding: 15px; border-radius: 4px; overflow-x: auto; font-family: monospace; font-size: 0.9em; }
.error-info { margin-bottom: 20px; }
.request-id { font-family: monospace; color: #6c757d; }
.render-error { margin-top: 30px; padding-top: 20px; border-top: 1px dashed #dee2e6; }
.security-warning { background: #fff3cd; border: 1px solid #ffeaa7; padding: 15px; border-radius: 4px; margin: 15px 0; }
.debug-section { margin-top: 20px; padding: 15px; background: #f8f9fa; border-radius: 4px; }
</style>
</head>
<body>
<div class="error-container">
<h1 class="error-title">%s: %s</h1>
%s
<div class="error-meta">
<strong>Request-ID:</strong> <span class="request-id">%s</span><br>
<strong>Zeit:</strong> %s<br>
<strong>Fehler-Level:</strong> %s<br>
<strong>HTTP-Status:</strong> %d<br>
<strong>Component:</strong> %s<br>
<strong>Operation:</strong> %s
</div>
<div class="error-info">
<p><strong>Client-IP:</strong> %s</p>
<p><strong>Request-URI:</strong> %s</p>
<p><strong>User-Agent:</strong> %s</p>
</div>
<div class="debug-section">
<h3>Exception Data:</h3>
<pre>%s</pre>
</div>
%s
</div>
</body>
</html>',
htmlspecialchars($context->exception->component ?? 'Application'),
htmlspecialchars($errorMessage),
$isSecurityEvent ? '<div class="security-warning"><strong>⚠️ Security Event:</strong> This error indicates a potential security issue.</div>' : '',
htmlspecialchars($context->request->requestId?->toString() ?? 'unknown'),
htmlspecialchars(date('c')),
htmlspecialchars($context->metadata['error_level'] ?? 'ERROR'),
$context->metadata['http_status'] ?? 500,
htmlspecialchars($context->exception->component ?? 'Application'),
htmlspecialchars($context->exception->operation ?? 'unknown'),
htmlspecialchars($context->request->clientIp ?? 'unknown'),
htmlspecialchars($context->request->requestUri ?? '/'),
htmlspecialchars(substr((string)$context->request->userAgent ?? '', 0, 100)),
htmlspecialchars(json_encode($context->exception->data, JSON_PRETTY_PRINT)),
$renderException ? '<div class="render-error"><h3>Fehler beim Rendern der Fehlerseite:</h3><p>' .
htmlspecialchars($renderException->getMessage()) . '</p></div>' : ''
);
} else {
// Produktions-Fallback mit minimalen Informationen
$userMessage = $isSecurityEvent ? 'Access denied.' : 'Es ist ein Fehler aufgetreten.';
$content = sprintf(
'<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Fehler</title>
<style>
body { font-family: sans-serif; line-height: 1.6; color: #333; max-width: 800px; margin: 0 auto; padding: 40px 20px; text-align: center; }
.error-container { background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 4px; padding: 40px 20px; }
.error-title { color: #dc3545; margin-top: 0; }
.request-id { font-family: monospace; color: #6c757d; font-size: 0.8em; margin-top: 30px; }
.status-code { font-size: 4em; font-weight: bold; color: #dc3545; margin: 20px 0; }
</style>
</head>
<body>
<div class="error-container">
<div class="status-code">%d</div>
<h1 class="error-title">%s</h1>
<p>Bitte versuchen Sie es später erneut oder kontaktieren Sie den Support.</p>
<p class="request-id">Request-ID: %s</p>
</div>
</body>
</html>',
$context->metadata['http_status'] ?? 500,
htmlspecialchars($userMessage),
htmlspecialchars($context->request->requestId?->toString() ?? 'unknown')
);
}
return $content;
}
}

View File

@@ -0,0 +1,106 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Service Unavailable</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: #1a202c;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
background: #2d3748;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
max-width: 500px;
width: 100%;
padding: 48px 32px;
text-align: center;
}
.status {
font-size: 72px;
font-weight: 700;
color: #667eea;
margin-bottom: 16px;
line-height: 1;
}
h1 {
font-size: 24px;
margin-bottom: 16px;
color: #f7fafc;
font-weight: 600;
}
p {
color: #cbd5e0;
line-height: 1.6;
margin-bottom: 24px;
font-size: 16px;
}
.request-id {
font-family: 'Courier New', Courier, monospace;
background: #1a202c;
padding: 12px 16px;
border-radius: 8px;
font-size: 13px;
color: #e2e8f0;
word-break: break-all;
border: 1px solid #4a5568;
}
.request-id strong {
display: block;
margin-bottom: 4px;
color: #a0aec0;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
@media (max-width: 600px) {
.container {
padding: 32px 24px;
}
.status {
font-size: 56px;
}
h1 {
font-size: 20px;
}
p {
font-size: 14px;
}
}
</style>
</head>
<body>
<div class="container">
<div class="status">503</div>
<h1>Service Temporarily Unavailable</h1>
<p>We're experiencing technical difficulties. Our team has been notified and is working on a solution.</p>
<div class="request-id">
<strong>Request ID</strong>
{REQUEST_ID}
</div>
</div>
</body>
</html>

View File

@@ -1,207 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fehler: $errorClass</title>
<style>
:root {
--bg-color: #f8f9fa;
--text-color: #212529;
--border-color: #dee2e6;
--error-color: #dc3545;
--header-bg: #e9ecef;
--code-bg: #f5f5f5;
--link-color: #0d6efd;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-color: #212529;
--text-color: #f8f9fa;
--border-color: #495057;
--error-color: #f8d7da;
--header-bg: #343a40;
--code-bg: #2b3035;
--link-color: #6ea8fe;
}
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.6;
color: var(--text-color);
background: var(--bg-color);
margin: 0;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.error-container {
background: white;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
overflow: hidden;
margin-bottom: 20px;
}
.error-header {
background: var(--header-bg);
padding: 15px 20px;
border-bottom: 1px solid var(--border-color);
}
.error-title {
margin: 0;
color: var(--error-color);
font-size: 1.5rem;
}
.error-body {
padding: 20px;
}
.error-message {
font-size: 1.1rem;
margin-bottom: 20px;
padding: 15px;
background: #fff5f5;
border-left: 4px solid var(--error-color);
border-radius: 4px;
}
.error-meta {
display: flex;
flex-wrap: wrap;
gap: 10px;
font-size: 0.9rem;
color: #6c757d;
margin-bottom: 20px;
}
.error-meta span {
background: var(--header-bg);
padding: 4px 8px;
border-radius: 4px;
}
.trace-container {
background: white;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.trace-header {
background: var(--header-bg);
padding: 5px 10px;
border-bottom: 1px solid var(--border-color);
}
.trace-title {
margin: 0;
font-size: 1.2rem;
}
.trace-body {
padding: 0;
}
.trace-item {
padding: 2px 4px;
border-bottom: 1px solid var(--border-color);
}
.trace-item:last-child {
border-bottom: none;
}
.trace-location {
font-family: monospace;
background: var(--code-bg);
padding: 4px 8px;
border-radius: 4px;
word-break: break-all;
}
.trace-call {
margin-top: 5px;
font-family: monospace;
color: #6c757d;
word-break: break-all;
}
.trace-origin {
background-color: #fff5f5;
}
code {
font-family: monospace;
background: var(--code-bg);
padding: 2px 4px;
border-radius: 3px;
}
@media (prefers-color-scheme: dark) {
.error-container, .trace-container {
background: #343a40;
}
.error-message {
background: #3a2a2a;
}
.trace-origin {
background-color: #3a2a2a;
}
}
</style>
</head>
<body>
<div class="container">
<div class="error-container">
<div class="error-header">
<h1 class="error-title">{{ errorClass }}</h1>
</div>
<div class="error-body">
<div class="error-message">{{ errorMessage }}</div>
<div class="error-meta">
<span>Request ID: {{ requestId }}</span>
<span>Zeit: {{ timestamp }}</span>
<span>Level: {{ level }}</span>
<span>Speicherverbrauch: {{ memory }}</span>
</div>
{{ dependencyInfo }}
</div>
</div>
{{ code }}
<div class="trace-container">
<div class="trace-header">
<h2 class="trace-title">Stack Trace</h2>
</div>
<div class="trace-body" style="font-size: 0.8rem;">
<for var="item" in="trace">
<div class="trace-item {{ item.isExceptionOrigin ? 'trace-origin' : '' }}">
<div class="trace-location">
{{ item.getRelativeFile() }} <i>Line {{ item.line }}</i>
</div>
<div class="trace-call" style="font-size: 1.25rem; background-color: #81a5ed; color: black; padding: 0.25em;">
{{ item.getCallString() }}
</div>
</div>
</for>
</div>
</div>
</div>
</body>
</html>

View File

@@ -1,720 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🐛 Debug: {{ errorClass }}</title>
<style>
:root {
--primary: #1e40af;
--danger: #dc2626;
--warning: #d97706;
--success: #059669;
--info: #0284c7;
--gray-50: #f9fafb;
--gray-100: #f3f4f6;
--gray-200: #e5e7eb;
--gray-300: #d1d5db;
--gray-400: #9ca3af;
--gray-500: #6b7280;
--gray-600: #4b5563;
--gray-700: #374151;
--gray-800: #1f2937;
--gray-900: #111827;
}
@media (prefers-color-scheme: dark) {
:root {
--gray-50: #111827;
--gray-100: #1f2937;
--gray-200: #374151;
--gray-300: #4b5563;
--gray-400: #6b7280;
--gray-500: #9ca3af;
--gray-600: #d1d5db;
--gray-700: #e5e7eb;
--gray-800: #f3f4f6;
--gray-900: #f9fafb;
}
}
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Inter", sans-serif;
font-size: 14px;
line-height: 1.5;
color: var(--gray-900);
background: var(--gray-50);
margin: 0;
padding: 0;
}
/* Layout */
.debug-layout {
display: grid;
grid-template-columns: 320px 1fr;
min-height: 100vh;
gap: 0;
}
@media (max-width: 1024px) {
.debug-layout {
grid-template-columns: 1fr;
}
.debug-sidebar {
order: 2;
border-top: 1px solid var(--gray-200);
border-right: none;
}
}
/* Sidebar */
.debug-sidebar {
background: var(--gray-100);
border-right: 1px solid var(--gray-200);
overflow-y: auto;
max-height: 100vh;
}
.debug-nav {
list-style: none;
margin: 0;
padding: 0;
}
.debug-nav-item {
border-bottom: 1px solid var(--gray-200);
}
.debug-nav-link {
display: block;
padding: 12px 16px;
color: var(--gray-700);
text-decoration: none;
font-weight: 500;
transition: all 0.15s;
position: relative;
}
.debug-nav-link:hover {
background: var(--gray-200);
color: var(--primary);
}
.debug-nav-link.active {
background: var(--primary);
color: white;
}
.debug-nav-link .count {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
background: var(--gray-300);
color: var(--gray-700);
font-size: 11px;
padding: 2px 6px;
border-radius: 10px;
}
.debug-nav-link.active .count {
background: rgba(255,255,255,0.2);
color: white;
}
/* Main Content */
.debug-main {
background: white;
overflow-y: auto;
max-height: 100vh;
}
/* Header */
.debug-header {
background: linear-gradient(135deg, var(--danger) 0%, #dc2626 100%);
color: white;
padding: 24px;
position: sticky;
top: 0;
z-index: 10;
border-bottom: 1px solid var(--gray-200);
}
.debug-header h1 {
margin: 0 0 8px 0;
font-size: 24px;
font-weight: 700;
}
.debug-header p {
margin: 0;
opacity: 0.9;
font-size: 16px;
}
.debug-meta {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 16px;
}
.debug-badge {
background: rgba(255,255,255,0.2);
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
/* Content Sections */
.debug-content {
padding: 24px;
}
.debug-section {
display: none;
margin-bottom: 32px;
}
.debug-section.active {
display: block;
}
.debug-section h2 {
margin: 0 0 16px 0;
font-size: 18px;
font-weight: 600;
color: var(--gray-800);
}
/* Cards */
.debug-card {
background: white;
border: 1px solid var(--gray-200);
border-radius: 8px;
margin-bottom: 16px;
overflow: hidden;
}
.debug-card-header {
background: var(--gray-50);
border-bottom: 1px solid var(--gray-200);
padding: 12px 16px;
font-weight: 600;
color: var(--gray-800);
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
}
.debug-card-header:hover {
background: var(--gray-100);
}
.debug-card-content {
padding: 16px;
}
.debug-card.collapsed .debug-card-content {
display: none;
}
.debug-card-header::after {
content: '';
font-weight: bold;
transform: rotate(0deg);
transition: transform 0.2s;
}
.debug-card.collapsed .debug-card-header::after {
transform: rotate(90deg);
content: '+';
}
/* Code */
.debug-code {
background: var(--gray-900);
color: var(--gray-100);
padding: 16px;
border-radius: 6px;
font-family: 'SF Mono', Monaco, 'Inconsolata', 'Roboto Mono', monospace;
font-size: 13px;
line-height: 1.4;
overflow-x: auto;
position: relative;
}
.debug-code-line {
position: relative;
padding-left: 60px;
}
.debug-code-line-number {
position: absolute;
left: 0;
width: 50px;
color: var(--gray-500);
text-align: right;
user-select: none;
}
.debug-code-line.highlight {
background: rgba(239, 68, 68, 0.1);
border-left: 3px solid var(--danger);
}
/* Stack Trace */
.debug-trace-item {
border-bottom: 1px solid var(--gray-200);
transition: all 0.15s;
}
.debug-trace-item:last-child {
border-bottom: none;
}
.debug-trace-item:hover {
background: var(--gray-50);
}
.debug-trace-item.origin {
background: rgba(239, 68, 68, 0.05);
border-left: 4px solid var(--danger);
}
.debug-trace-header {
padding: 12px 16px;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
}
.debug-trace-location {
font-family: monospace;
font-size: 13px;
color: var(--primary);
font-weight: 600;
}
.debug-trace-call {
font-family: monospace;
font-size: 12px;
color: var(--gray-600);
margin-top: 4px;
}
/* Tables */
.debug-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.debug-table th,
.debug-table td {
padding: 8px 12px;
text-align: left;
border-bottom: 1px solid var(--gray-200);
}
.debug-table th {
background: var(--gray-50);
font-weight: 600;
color: var(--gray-700);
}
.debug-table td {
font-family: monospace;
font-size: 12px;
}
/* Search */
.debug-search {
position: sticky;
top: 0;
background: white;
padding: 16px;
border-bottom: 1px solid var(--gray-200);
z-index: 5;
}
.debug-search input {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--gray-300);
border-radius: 6px;
font-size: 14px;
}
.debug-search input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
/* Utilities */
.text-sm { font-size: 12px; }
.text-xs { font-size: 11px; }
.font-mono { font-family: monospace; }
.font-semibold { font-weight: 600; }
.text-gray-500 { color: var(--gray-500); }
.text-gray-600 { color: var(--gray-600); }
.text-red-600 { color: var(--danger); }
.bg-red-50 { background: rgba(239, 68, 68, 0.05); }
.hidden { display: none; }
/* Mobile Responsive */
@media (max-width: 768px) {
.debug-layout {
grid-template-columns: 1fr;
}
.debug-header {
padding: 16px;
}
.debug-content {
padding: 16px;
}
.debug-meta {
flex-direction: column;
gap: 8px;
}
}
</style>
</head>
<body>
<div class="debug-layout">
<!-- Sidebar -->
<nav class="debug-sidebar">
<ul class="debug-nav">
<li class="debug-nav-item">
<a href="#overview" class="debug-nav-link active" data-section="overview">
Overview
</a>
</li>
<li class="debug-nav-item">
<a href="#stacktrace" class="debug-nav-link" data-section="stacktrace">
Stack Trace
<span class="count">{{ traceCount }}</span>
</a>
</li>
<li class="debug-nav-item">
<a href="#request" class="debug-nav-link" data-section="request">
Request
</a>
</li>
<li class="debug-nav-item">
<a href="#context" class="debug-nav-link" data-section="context">
Context
</a>
</li>
<li class="debug-nav-item">
<a href="#performance" class="debug-nav-link" data-section="performance">
Performance
</a>
</li>
<li class="debug-nav-item">
<a href="#environment" class="debug-nav-link" data-section="environment">
Environment
</a>
</li>
</ul>
</nav>
<!-- Main Content -->
<main class="debug-main">
<!-- Header -->
<header class="debug-header">
<h1>{{ errorClass }}</h1>
<p>{{ errorMessage }}</p>
<div class="debug-meta">
<span class="debug-badge">Request ID: {{ requestId }}</span>
<span class="debug-badge">{{ timestamp }}</span>
<span class="debug-badge">Memory: {{ memory }}</span>
<span class="debug-badge">{{ level }}</span>
</div>
</header>
<div class="debug-content">
<!-- Overview Section -->
<section id="overview" class="debug-section active">
<h2>Exception Overview</h2>
<div class="debug-card">
<div class="debug-card-header">
Error Details
</div>
<div class="debug-card-content">
<table class="debug-table">
<tr>
<th>Exception</th>
<td>{{ errorClass }}</td>
</tr>
<tr>
<th>Message</th>
<td>{{ errorMessage }}</td>
</tr>
<tr>
<th>File</th>
<td class="font-mono">{{ errorFile }}</td>
</tr>
<tr>
<th>Line</th>
<td>{{ errorLine }}</td>
</tr>
<tr>
<th>Code</th>
<td>{{ errorCode }}</td>
</tr>
</table>
</div>
</div>
{{ code }}
{{ dependencyInfo }}
</section>
<!-- Stack Trace Section -->
<section id="stacktrace" class="debug-section">
<div class="debug-search">
<input type="text" id="trace-search" placeholder="Search stack trace...">
</div>
<h2>Stack Trace</h2>
<div class="debug-card">
<div class="debug-card-content" style="padding: 0;">
<div style="padding: 16px;">
<p>Trace Count: {{ traceCount }}</p>
<for var="item" in="trace">
<div class="debug-trace-item {{ item.isOrigin }}" data-searchable="{{ item.file }} {{ item.class }}">
<div class="debug-trace-header">
<div>
<div class="debug-trace-location">
{{ item.file }}:{{ item.line }}
</div>
<div class="debug-trace-call">
{{ item.function }}
</div>
</div>
<span class="text-xs text-gray-500">#{{ item.index }}</span>
</div>
</div>
</for>
</div>
</div>
</div>
</section>
<!-- Request Section -->
<section id="request" class="debug-section">
<h2>Request Information</h2>
<div class="debug-card">
<div class="debug-card-header">
HTTP Request
</div>
<div class="debug-card-content">
<table class="debug-table">
<tr>
<th>Method</th>
<td>{{ requestMethod }}</td>
</tr>
<tr>
<th>URI</th>
<td class="font-mono">{{ requestUri }}</td>
</tr>
<tr>
<th>User Agent</th>
<td class="text-sm">{{ userAgent }}</td>
</tr>
<tr>
<th>IP Address</th>
<td>{{ clientIp }}</td>
</tr>
</table>
</div>
</div>
<div class="debug-card collapsed">
<div class="debug-card-header">
Request Headers
</div>
<div class="debug-card-content">
<table class="debug-table">
<tr><th>Accept</th><td>application/json, text/html</td></tr>
<tr><th>Accept-Language</th><td>de-DE,de;q=0.9,en;q=0.8</td></tr>
<tr><th>Cache-Control</th><td>no-cache</td></tr>
</table>
</div>
</div>
<div class="debug-card collapsed">
<div class="debug-card-header">
Request Parameters
</div>
<div class="debug-card-content">
<p class="text-sm text-gray-500">No parameters found</p>
</div>
</div>
</section>
<!-- Context Section -->
<section id="context" class="debug-section">
<h2>Application Context</h2>
<div class="debug-card">
<div class="debug-card-header">
Application State
</div>
<div class="debug-card-content">
<table class="debug-table">
<tr>
<th>Environment</th>
<td>{{ environment }}</td>
</tr>
<tr>
<th>Debug Mode</th>
<td>{{ debugMode }}</td>
</tr>
<tr>
<th>PHP Version</th>
<td>{{ phpVersion }}</td>
</tr>
<tr>
<th>Framework Version</th>
<td>{{ frameworkVersion }}</td>
</tr>
</table>
</div>
</div>
</section>
<!-- Performance Section -->
<section id="performance" class="debug-section">
<h2>Performance Metrics</h2>
<div class="debug-card">
<div class="debug-card-header">
Memory & Timing
</div>
<div class="debug-card-content">
<table class="debug-table">
<tr>
<th>Peak Memory</th>
<td>{{ memory }}</td>
</tr>
<tr>
<th>Execution Time</th>
<td>{{ executionTime }}</td>
</tr>
<tr>
<th>Memory Limit</th>
<td>{{ memoryLimit }}</td>
</tr>
</table>
</div>
</div>
</section>
<!-- Environment Section -->
<section id="environment" class="debug-section">
<h2>Environment</h2>
<div class="debug-card collapsed">
<div class="debug-card-header">
Server Information
</div>
<div class="debug-card-content">
<table class="debug-table">
<tr><th>Server Software</th><td>{{ serverSoftware }}</td></tr>
<tr><th>Document Root</th><td class="font-mono">{{ documentRoot }}</td></tr>
<tr><th>Server Name</th><td>{{ serverName }}</td></tr>
</table>
</div>
</div>
</section>
</div>
</main>
</div>
<script>
// Navigation
document.querySelectorAll('.debug-nav-link').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
// Update active nav
document.querySelectorAll('.debug-nav-link').forEach(l => l.classList.remove('active'));
link.classList.add('active');
// Show section
const sectionId = link.dataset.section;
document.querySelectorAll('.debug-section').forEach(s => s.classList.remove('active'));
document.getElementById(sectionId).classList.add('active');
});
});
// Collapsible cards
document.querySelectorAll('.debug-card-header').forEach(header => {
header.addEventListener('click', () => {
header.parentElement.classList.toggle('collapsed');
});
});
// Search functionality
const searchInput = document.getElementById('trace-search');
if (searchInput) {
searchInput.addEventListener('input', (e) => {
const query = e.target.value.toLowerCase();
document.querySelectorAll('.debug-trace-item').forEach(item => {
const searchText = item.dataset.searchable?.toLowerCase() || '';
item.style.display = searchText.includes(query) ? 'block' : 'none';
});
});
}
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.ctrlKey || e.metaKey) {
switch(e.key) {
case '1':
e.preventDefault();
document.querySelector('[data-section="overview"]').click();
break;
case '2':
e.preventDefault();
document.querySelector('[data-section="stacktrace"]').click();
break;
case '3':
e.preventDefault();
document.querySelector('[data-section="request"]').click();
break;
case 'f':
e.preventDefault();
searchInput?.focus();
break;
}
}
});
// Auto-expand first trace item if it's the origin
document.addEventListener('DOMContentLoaded', () => {
const originTrace = document.querySelector('.debug-trace-item.origin');
if (originTrace) {
originTrace.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
});
</script>
</body>
</html>

View File

@@ -1,111 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name='robots' content='noindex, nofollow, noarchive'/>
<title>Fehler</title>
<style>
:root {
--bg-color: #f8f9fa;
--text-color: #212529;
--border-color: #dee2e6;
--error-color: #dc3545;
--header-bg: #e9ecef;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-color: #212529;
--text-color: #f8f9fa;
--border-color: #495057;
--error-color: #f8d7da;
--header-bg: #343a40;
}
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.6;
color: var(--text-color);
background: var(--bg-color);
margin: 0;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
.container {
}
.error-container {
background: white;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.error-header {
background: var(--header-bg);
padding: 15px 20px;
border-bottom: 1px solid var(--border-color);
}
.error-title {
margin: 0;
color: var(--error-color);
font-size: 1.5rem;
}
.error-body {
padding: 30px 20px;
text-align: center;
}
.error-message {
font-size: 1.1rem;
margin-bottom: 20px;
}
.error-footer {
display: flex;
justify-content: center;
gap: 10px;
padding: 20px;
border-top: 1px solid var(--border-color);
}
@media (prefers-color-scheme: dark) {
.error-container {
background: #343a40;
}
}
</style>
</head>
<body>
<main class="container" role="main">
<section class="error-container" aria-live="assertive">
<header class="error-header">
<h1 class="error-title">Uups, da ist etwas schiefgelaufen</h1>
</header>
<div class="error-body">
<div class="error-message">
Es tut uns leid, aber es ist ein Fehler aufgetreten.
Unser Team wurde benachrichtigt und arbeitet an einer Lösung.
</div>
</div>
<footer class="error-footer">
<button onclick="location.reload();">Neu Laden</button>
<a href="/" title="Zur Startseite">Startseite</a>
<button onclick="history.back();">Zurück</button>
</footer>
</section>
</main>
</body>
</html>

View File

@@ -0,0 +1,231 @@
<?php
declare(strict_types=1);
namespace App\Framework\ErrorReporting;
use App\Framework\Config\Environment;
use App\Framework\Config\EnvironmentType;
use App\Framework\Config\EnvKey;
/**
* Error Reporting Configuration that adapts to environment
*
* Provides type-safe configuration for error reporting system
* with environment-specific defaults (dev/staging/production).
*
* Features:
* - Environment-based factory methods
* - Type-safe configuration properties
* - Framework-compliant readonly Value Object
* - Sensible defaults per environment
*/
final readonly class ErrorReportingConfig
{
/**
* @param bool $enabled Enable/disable error reporting globally
* @param bool $asyncProcessing Process error reports asynchronously via queue
* @param string[] $filterLevels Only report these log levels (empty = all levels)
* @param string[] $excludedExceptionTypes Exception types to exclude from reporting
* @param bool $captureRequestContext Capture HTTP request context
* @param bool $captureUserContext Capture user/session context
* @param bool $captureStackTraces Include full stack traces in reports
* @param int $maxStackTraceDepth Maximum stack trace depth
* @param bool $sanitizeSensitiveData Sanitize passwords, tokens, etc.
* @param int $samplingRate Sampling rate for high-volume errors (1-100, 100 = all)
* @param int $maxReportsPerMinute Rate limit for error reports
* @param bool $enableAnalytics Enable error analytics and anomaly detection
* @param int $analyticsRetentionDays Days to retain error reports for analytics
*/
public function __construct(
public bool $enabled = true,
public bool $asyncProcessing = true,
public array $filterLevels = [],
public array $excludedExceptionTypes = [],
public bool $captureRequestContext = true,
public bool $captureUserContext = true,
public bool $captureStackTraces = true,
public int $maxStackTraceDepth = 20,
public bool $sanitizeSensitiveData = true,
public int $samplingRate = 100,
public int $maxReportsPerMinute = 60,
public bool $enableAnalytics = true,
public int $analyticsRetentionDays = 30
) {
// Validation
if ($samplingRate < 1 || $samplingRate > 100) {
throw new \InvalidArgumentException('Sampling rate must be between 1 and 100');
}
if ($maxStackTraceDepth < 1) {
throw new \InvalidArgumentException('Max stack trace depth must be at least 1');
}
if ($analyticsRetentionDays < 1) {
throw new \InvalidArgumentException('Analytics retention days must be at least 1');
}
}
/**
* Create configuration from environment
*/
public static function fromEnvironment(Environment $env): self
{
$appEnv = $env->getString(EnvKey::APP_ENV, 'production');
$environmentType = match (strtolower($appEnv)) {
'production', 'prod' => EnvironmentType::PROD,
'staging', 'stage' => EnvironmentType::STAGING,
'development', 'dev', 'local' => EnvironmentType::DEV,
default => EnvironmentType::PROD
};
return self::forEnvironment($environmentType, $env);
}
/**
* Create environment-specific configuration
*/
public static function forEnvironment(EnvironmentType $environment, Environment $env): self
{
return match ($environment) {
EnvironmentType::PROD => self::production($env),
EnvironmentType::STAGING => self::staging($env),
EnvironmentType::DEV => self::development($env),
};
}
/**
* Production configuration - strict, sanitized, sampled
*/
private static function production(Environment $env): self
{
$filterLevels = $env->getString('ERROR_REPORTING_FILTER_LEVELS', '');
$excludedTypes = $env->getString('ERROR_REPORTING_EXCLUDED_TYPES', '');
// Production default: only error, critical, alert, emergency
$defaultFilterLevels = ['error', 'critical', 'alert', 'emergency'];
return new self(
enabled: $env->getBool('ERROR_REPORTING_ENABLED', true),
asyncProcessing: $env->getBool('ERROR_REPORTING_ASYNC', true),
filterLevels: $filterLevels !== ''
? explode(',', $filterLevels)
: $defaultFilterLevels,
excludedExceptionTypes: $excludedTypes !== ''
? explode(',', $excludedTypes)
: [],
captureRequestContext: $env->getBool('ERROR_REPORTING_CAPTURE_REQUEST', true),
captureUserContext: $env->getBool('ERROR_REPORTING_CAPTURE_USER', true),
captureStackTraces: $env->getBool('ERROR_REPORTING_CAPTURE_STACK_TRACES', true),
maxStackTraceDepth: $env->getInt('ERROR_REPORTING_MAX_STACK_DEPTH', 15),
sanitizeSensitiveData: $env->getBool('ERROR_REPORTING_SANITIZE', true),
samplingRate: $env->getInt('ERROR_REPORTING_SAMPLING_RATE', 100),
maxReportsPerMinute: $env->getInt('ERROR_REPORTING_MAX_PER_MINUTE', 30),
enableAnalytics: $env->getBool('ERROR_REPORTING_ANALYTICS', true),
analyticsRetentionDays: $env->getInt('ERROR_REPORTING_RETENTION_DAYS', 30)
);
}
/**
* Staging configuration - more verbose, less restrictive
*/
private static function staging(Environment $env): self
{
$filterLevels = $env->getString('ERROR_REPORTING_FILTER_LEVELS', '');
$excludedTypes = $env->getString('ERROR_REPORTING_EXCLUDED_TYPES', '');
// Staging default: warning and above
$defaultFilterLevels = ['warning', 'error', 'critical', 'alert', 'emergency'];
return new self(
enabled: $env->getBool('ERROR_REPORTING_ENABLED', true),
asyncProcessing: $env->getBool('ERROR_REPORTING_ASYNC', true),
filterLevels: $filterLevels !== ''
? explode(',', $filterLevels)
: $defaultFilterLevels,
excludedExceptionTypes: $excludedTypes !== ''
? explode(',', $excludedTypes)
: [],
captureRequestContext: $env->getBool('ERROR_REPORTING_CAPTURE_REQUEST', true),
captureUserContext: $env->getBool('ERROR_REPORTING_CAPTURE_USER', true),
captureStackTraces: $env->getBool('ERROR_REPORTING_CAPTURE_STACK_TRACES', true),
maxStackTraceDepth: $env->getInt('ERROR_REPORTING_MAX_STACK_DEPTH', 20),
sanitizeSensitiveData: $env->getBool('ERROR_REPORTING_SANITIZE', true),
samplingRate: $env->getInt('ERROR_REPORTING_SAMPLING_RATE', 100),
maxReportsPerMinute: $env->getInt('ERROR_REPORTING_MAX_PER_MINUTE', 60),
enableAnalytics: $env->getBool('ERROR_REPORTING_ANALYTICS', true),
analyticsRetentionDays: $env->getInt('ERROR_REPORTING_RETENTION_DAYS', 14)
);
}
/**
* Development configuration - verbose, everything enabled
*/
private static function development(Environment $env): self
{
$filterLevels = $env->getString('ERROR_REPORTING_FILTER_LEVELS', '');
$excludedTypes = $env->getString('ERROR_REPORTING_EXCLUDED_TYPES', '');
return new self(
enabled: $env->getBool('ERROR_REPORTING_ENABLED', true),
asyncProcessing: $env->getBool('ERROR_REPORTING_ASYNC', false), // Sync in dev for debugging
filterLevels: $filterLevels !== '' ? explode(',', $filterLevels) : [], // All levels
excludedExceptionTypes: $excludedTypes !== '' ? explode(',', $excludedTypes) : [],
captureRequestContext: $env->getBool('ERROR_REPORTING_CAPTURE_REQUEST', true),
captureUserContext: $env->getBool('ERROR_REPORTING_CAPTURE_USER', true),
captureStackTraces: $env->getBool('ERROR_REPORTING_CAPTURE_STACK_TRACES', true),
maxStackTraceDepth: $env->getInt('ERROR_REPORTING_MAX_STACK_DEPTH', 30), // Deep traces
sanitizeSensitiveData: $env->getBool('ERROR_REPORTING_SANITIZE', false), // See real data
samplingRate: $env->getInt('ERROR_REPORTING_SAMPLING_RATE', 100), // All errors
maxReportsPerMinute: $env->getInt('ERROR_REPORTING_MAX_PER_MINUTE', 1000), // No limit
enableAnalytics: $env->getBool('ERROR_REPORTING_ANALYTICS', true),
analyticsRetentionDays: $env->getInt('ERROR_REPORTING_RETENTION_DAYS', 7)
);
}
/**
* Check if error level should be reported
*/
public function shouldReportLevel(string $level): bool
{
// Empty filter = all levels
if (empty($this->filterLevels)) {
return true;
}
return in_array(strtolower($level), array_map('strtolower', $this->filterLevels), true);
}
/**
* Check if exception type should be reported
*/
public function shouldReportException(\Throwable $exception): bool
{
if (empty($this->excludedExceptionTypes)) {
return true;
}
$exceptionClass = get_class($exception);
foreach ($this->excludedExceptionTypes as $excludedType) {
if ($exceptionClass === $excludedType || is_subclass_of($exceptionClass, $excludedType)) {
return false;
}
}
return true;
}
/**
* Check if error should be sampled (for high-volume scenarios)
*/
public function shouldSample(): bool
{
if ($this->samplingRate === 100) {
return true;
}
return random_int(1, 100) <= $this->samplingRate;
}
}

View File

@@ -17,6 +17,7 @@ use App\Framework\ErrorReporting\Storage\ErrorReportStorageInterface;
use App\Framework\Http\Session\Session;
use App\Framework\Logging\Logger;
use App\Framework\Queue\Queue;
use App\Framework\ErrorReporting\ErrorReportingConfig;
/**
* Initializer for Error Reporting system
@@ -27,17 +28,15 @@ final readonly class ErrorReportingInitializer
public function initialize(Container $container): void
{
$env = $container->get(Environment::class);
$enabled = $env->getBool('ERROR_REPORTING_ENABLED', true);
// Create configuration from environment
$config = ErrorReportingConfig::fromEnvironment($env);
// Register config as singleton
$container->singleton(ErrorReportingConfig::class, $config);
// Storage
$container->bind(ErrorReportStorageInterface::class, function (Container $container) use ($enabled) {
if (! $enabled) {
// Return storage even if disabled (might be used for queries)
return new DatabaseErrorReportStorage(
connection: $container->get(ConnectionInterface::class)
);
}
$container->bind(ErrorReportStorageInterface::class, function (Container $container) {
return new DatabaseErrorReportStorage(
connection: $container->get(ConnectionInterface::class)
);
@@ -52,106 +51,28 @@ final readonly class ErrorReportingInitializer
});
// Error Reporter Interface - bind to concrete or Null implementation
$container->bind(ErrorReporterInterface::class, function (Container $container) use ($enabled) {
if (! $enabled) {
$container->bind(ErrorReporterInterface::class, function (Container $container) use ($config) {
if (!$config->enabled) {
return new NullErrorReporter();
}
$env = $container->get(Environment::class);
$processors = [];
$filters = [];
// Add built-in processors
if ($container->has(RequestContextProcessor::class)) {
$processors[] = $container->get(RequestContextProcessor::class);
}
if ($container->has(UserContextProcessor::class)) {
$processors[] = $container->get(UserContextProcessor::class);
}
// Add environment-based filters
$filterLevels = $env->getString('ERROR_REPORTING_FILTER_LEVELS', '');
if ($filterLevels !== '') {
$allowedLevels = explode(',', $filterLevels);
$filters[] = function (ErrorReport $report) use ($allowedLevels) {
return in_array($report->level, $allowedLevels);
};
}
// Add environment filter for production
$appEnv = $env->getString('APP_ENV', 'production');
if ($appEnv === 'production') {
$filters[] = function (ErrorReport $report) {
// Don't report debug/info in production
return ! in_array($report->level, ['debug', 'info']);
};
}
return new ErrorReporter(
storage: $container->get(ErrorReportStorageInterface::class),
clock: $container->get(Clock::class),
logger: $container->has(Logger::class) ? $container->get(Logger::class) : null,
queue: $container->has(Queue::class) ? $container->get(Queue::class) : null,
asyncProcessing: $env->getBool('ERROR_REPORTING_ASYNC', true),
processors: $processors,
filters: $filters
);
return $this->createErrorReporter($container, $config);
});
// Error Reporter (concrete class) - delegate to interface
$container->bind(ErrorReporter::class, function (Container $container) use ($enabled) {
if (! $enabled) {
$container->bind(ErrorReporter::class, function (Container $container) use ($config) {
if (!$config->enabled) {
throw new \RuntimeException('ErrorReporter is disabled. Use ErrorReporterInterface instead.');
}
$env = $container->get(Environment::class);
$processors = [];
$filters = [];
// Add built-in processors
if ($container->has(RequestContextProcessor::class)) {
$processors[] = $container->get(RequestContextProcessor::class);
}
if ($container->has(UserContextProcessor::class)) {
$processors[] = $container->get(UserContextProcessor::class);
}
// Add environment-based filters
$filterLevels = $env->getString('ERROR_REPORTING_FILTER_LEVELS', '');
if ($filterLevels !== '') {
$allowedLevels = explode(',', $filterLevels);
$filters[] = function (ErrorReport $report) use ($allowedLevels) {
return in_array($report->level, $allowedLevels);
};
}
// Add environment filter for production
$appEnv = $env->getString('APP_ENV', 'production');
if ($appEnv === 'production') {
$filters[] = function (ErrorReport $report) {
// Don't report debug/info in production
return ! in_array($report->level, ['debug', 'info']);
};
}
return new ErrorReporter(
storage: $container->get(ErrorReportStorageInterface::class),
clock: $container->get(Clock::class),
logger: $container->has(Logger::class) ? $container->get(Logger::class) : null,
queue: $container->has(Queue::class) ? $container->get(Queue::class) : null,
asyncProcessing: $env->getBool('ERROR_REPORTING_ASYNC', true),
processors: $processors,
filters: $filters
);
return $this->createErrorReporter($container, $config);
});
// Middleware
$container->bind(ErrorReportingMiddleware::class, function (Container $container) use ($enabled) {
$container->bind(ErrorReportingMiddleware::class, function (Container $container) use ($config) {
return new ErrorReportingMiddleware(
reporter: $container->get(ErrorReporter::class),
enabled: $enabled
enabled: $config->enabled
);
});
@@ -166,4 +87,46 @@ final readonly class ErrorReportingInitializer
return new UserContextProcessor(null);
});
}
/**
* Create ErrorReporter instance with processors and filters from config
*/
private function createErrorReporter(Container $container, ErrorReportingConfig $config): ErrorReporter
{
$processors = [];
$filters = [];
// Add built-in processors based on config
if ($config->captureRequestContext && $container->has(RequestContextProcessor::class)) {
$processors[] = $container->get(RequestContextProcessor::class);
}
if ($config->captureUserContext && $container->has(UserContextProcessor::class)) {
$processors[] = $container->get(UserContextProcessor::class);
}
// Add level-based filter from config
if (!empty($config->filterLevels)) {
$filters[] = function (ErrorReport $report) use ($config) {
return $config->shouldReportLevel($report->level);
};
}
// Add sampling filter
if ($config->samplingRate < 100) {
$filters[] = function (ErrorReport $report) use ($config) {
return $config->shouldSample();
};
}
return new ErrorReporter(
storage: $container->get(ErrorReportStorageInterface::class),
clock: $container->get(Clock::class),
logger: $container->has(Logger::class) ? $container->get(Logger::class) : null,
queue: $container->has(Queue::class) ? $container->get(Queue::class) : null,
asyncProcessing: $config->asyncProcessing,
processors: $processors,
filters: $filters
);
}
}

View File

@@ -91,8 +91,8 @@ final readonly class DatabaseErrorReportStorage implements ErrorReportStorageInt
public function findRecent(int $limit = 100, int $offset = 0): array
{
$sql = '
SELECT * FROM error_reports
ORDER BY timestamp DESC
SELECT * FROM error_reports
ORDER BY timestamp DESC
LIMIT ? OFFSET ?
';
@@ -178,10 +178,10 @@ final readonly class DatabaseErrorReportStorage implements ErrorReportStorageInt
// Top errors by fingerprint
$topErrorsSql = '
SELECT fingerprint, exception, file, line, COUNT(*) as count, MAX(timestamp) as last_seen
FROM error_reports
WHERE timestamp BETWEEN ? AND ?
GROUP BY fingerprint, exception, file, line
ORDER BY count DESC
FROM error_reports
WHERE timestamp BETWEEN ? AND ?
GROUP BY fingerprint, exception, file, line
ORDER BY count DESC
LIMIT 10
';
$topErrorsResults = $this->connection->query($topErrorsSql, [$from->format('Y-m-d H:i:s'), $to->format('Y-m-d H:i:s')]);
@@ -240,7 +240,7 @@ final readonly class DatabaseErrorReportStorage implements ErrorReportStorageInt
$sql = "
SELECT DATE_FORMAT(timestamp, '{$groupFormat}') as period, COUNT(*) as count
FROM error_reports
FROM error_reports
WHERE timestamp BETWEEN ? AND ?
GROUP BY DATE_FORMAT(timestamp, '{$groupFormat}')
ORDER BY period
@@ -263,7 +263,7 @@ final readonly class DatabaseErrorReportStorage implements ErrorReportStorageInt
{
$sql = '
SELECT fingerprint, exception, message, file, line, COUNT(*) as count, MAX(timestamp) as last_seen
FROM error_reports
FROM error_reports
WHERE timestamp BETWEEN ? AND ?
GROUP BY fingerprint, exception, message, file, line
ORDER BY count DESC
@@ -291,9 +291,9 @@ final readonly class DatabaseErrorReportStorage implements ErrorReportStorageInt
public function findByFingerprint(string $fingerprint, int $limit = 100): array
{
$sql = '
SELECT * FROM error_reports
WHERE fingerprint = ?
ORDER BY timestamp DESC
SELECT * FROM error_reports
WHERE fingerprint = ?
ORDER BY timestamp DESC
LIMIT ?
';
@@ -330,9 +330,9 @@ final readonly class DatabaseErrorReportStorage implements ErrorReportStorageInt
// Table size
$sizeSql = "
SELECT
SELECT
ROUND(((data_length + index_length) / 1024 / 1024), 2) as size_mb
FROM information_schema.tables
FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = 'error_reports'
";
$sizeResult = $this->connection->query($sizeSql);

View File

@@ -33,7 +33,9 @@ final class InMemoryStorage implements Storage, StreamableStorage
public function __construct()
{
$this->permissions = new PermissionChecker($this);
// InMemoryStorage doesn't need real permission checks - create mock PathProvider
$mockPathProvider = new \App\Framework\Core\PathProvider('/tmp');
$this->permissions = new PermissionChecker($mockPathProvider);
$this->fiberManager = $this->createDefaultFiberManager();
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
/**
* Value Object for custom/unknown MIME types not in the MimeType enum
*/
final readonly class CustomMimeType implements MimeTypeInterface
{
private string $primaryType;
private string $subType;
public function __construct(
private string $value
) {
$this->validate($value);
[$this->primaryType, $this->subType] = $this->parse($value);
}
public static function fromString(string $mimeType): self
{
return new self($mimeType);
}
public function getValue(): string
{
return $this->value;
}
public function getPrimaryType(): string
{
return $this->primaryType;
}
public function getSubType(): string
{
return $this->subType;
}
public function equals(MimeTypeInterface $other): bool
{
return $this->value === $other->getValue();
}
public function __toString(): string
{
return $this->value;
}
private function validate(string $value): void
{
if (empty($value)) {
throw new \InvalidArgumentException('MIME type cannot be empty');
}
if (!str_contains($value, '/')) {
throw new \InvalidArgumentException("Invalid MIME type format: {$value}");
}
}
/**
* @return array{string, string}
*/
private function parse(string $value): array
{
$parts = explode('/', $value, 2);
return [$parts[0], $parts[1]];
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
use App\Framework\Filesystem\ValueObjects\FilePath;
/**
* Production MIME type detector using PHP's mime_content_type()
*
* Detects MIME types from actual file content, returns MimeType enum if known
* or CustomMimeType for unknown types
*/
final readonly class FilesystemMimeTypeDetector implements MimeTypeDetector
{
public function detect(FilePath $filePath): MimeTypeInterface
{
if (!$filePath->exists()) {
throw new \RuntimeException("File does not exist: {$filePath}");
}
$mimeTypeString = mime_content_type($filePath->toString());
if ($mimeTypeString === false) {
throw new \RuntimeException("Could not detect MIME type for: {$filePath}");
}
// Try to map to known MimeType enum
$mimeType = MimeType::tryFromString($mimeTypeString);
// If not found in enum, create CustomMimeType
return $mimeType ?? CustomMimeType::fromString($mimeTypeString);
}
}

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Framework\Http\Middlewares;
use App\Framework\ErrorHandling\SecurityEventLogger;
use App\Framework\Exception\SecurityEvent\SystemExcessiveUseEvent;
use App\Framework\Http\Headers;
use App\Framework\Http\HttpMiddleware;
@@ -17,6 +16,10 @@ use App\Framework\Http\RequestStateManager;
use App\Framework\Http\Response;
use App\Framework\Http\ResponseManipulator;
use App\Framework\Http\Status;
use App\Framework\Logging\Logger;
use App\Framework\Logging\Processors\SecurityEventProcessor;
use App\Framework\Logging\ValueObjects\LogContext;
use App\Framework\Logging\ValueObjects\SecurityContext;
use App\Framework\RateLimit\RateLimitConfig;
use App\Framework\RateLimit\RateLimiter;
use App\Framework\RateLimit\RateLimitResult;
@@ -33,8 +36,9 @@ final readonly class RateLimitMiddleware implements HttpMiddleware
public function __construct(
private RateLimiter $rateLimiter,
private ResponseManipulator $responseManipulator,
private RateLimitConfig $config = new RateLimitConfig(),
private ?SecurityEventLogger $securityLogger = null
private readonly Logger $logger,
private readonly SecurityEventProcessor $processor,
private RateLimitConfig $config = new RateLimitConfig()
) {
}
@@ -146,16 +150,30 @@ final readonly class RateLimitMiddleware implements HttpMiddleware
private function logSecurityEvent(string $clientIp, string $path, RateLimitResult $result): void
{
if (! $this->securityLogger) {
return;
}
// Create security event with correct constructor parameters
$event = new SystemExcessiveUseEvent(
null, // No user ID for IP-based limiting
$clientIp,
"Rate limit exceeded for {$path}: {$result->getCurrent()}/{$result->getLimit()} requests"
$result->getLimit(),
$result->getCurrent()
);
$this->securityLogger->log($event);
// Create SecurityContext for OWASP-compliant logging
$securityContext = SecurityContext::forIntrusion(
eventId: $event->getEventIdentifier(),
description: $event->getDescription(),
level: $event->getLogLevel(),
requiresAlert: $event->requiresAlert(),
eventData: array_merge($event->toArray(), ['path' => $path])
)->withRequestInfo($clientIp, null);
// Map SecurityLogLevel to framework LogLevel
$logLevel = $this->processor->mapSecurityLevelToLogLevel($event->getLogLevel());
// Log directly via Logger with SecurityContext
$this->logger->log(
$logLevel,
$event->getDescription(),
LogContext::empty()->withSecurityContext($securityContext)
);
}
}

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Framework\Http;
enum MimeType: string
enum MimeType: string implements MimeTypeInterface
{
// Text
case TEXT_PLAIN = 'text/plain';
@@ -68,6 +68,27 @@ enum MimeType: string
case FONT_TTF = 'font/ttf';
case FONT_OTF = 'font/otf';
// MimeTypeInterface implementation
public function getValue(): string
{
return $this->value;
}
public function getPrimaryType(): string
{
return explode('/', $this->value)[0];
}
public function getSubType(): string
{
return explode('/', $this->value)[1];
}
public function equals(MimeTypeInterface $other): bool
{
return $this->value === $other->getValue();
}
// Convenience methods using analyzer classes
public function isImage(): bool
{
@@ -119,17 +140,17 @@ enum MimeType: string
return MimeTypeResolver::getPreferredExtension($this);
}
public static function fromExtension(string $extension): ?self
public static function tryFromExtension(string $extension): ?self
{
return MimeTypeResolver::fromExtension($extension);
}
public static function fromFilePath(string $filePath): ?self
public static function tryFromFilePath(string $filePath): ?self
{
return MimeTypeResolver::fromFilePath($filePath);
}
public static function fromString(string $mimeTypeString): ?self
public static function tryFromString(string $mimeTypeString): ?self
{
return MimeTypeResolver::fromString($mimeTypeString);
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
use App\Framework\Filesystem\ValueObjects\FilePath;
/**
* Interface for detecting MIME types from file paths
*
* Returns MimeTypeInterface which can be either:
* - MimeType enum for known types
* - CustomMimeType for unknown types
*/
interface MimeTypeDetector
{
/**
* Detect MIME type from file path
*
* @return MimeTypeInterface The detected MIME type
*/
public function detect(FilePath $filePath): MimeTypeInterface;
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
/**
* Interface for MIME type representations
*
* Implemented by both MimeType enum (for known types) and CustomMimeType (for unknown types)
*/
interface MimeTypeInterface
{
/**
* Get the MIME type string value (e.g., 'image/jpeg', 'text/plain')
*/
public function getValue(): string;
/**
* Get the primary type (e.g., 'image', 'text', 'application')
*/
public function getPrimaryType(): string;
/**
* Get the sub type (e.g., 'jpeg', 'plain', 'json')
*/
public function getSubType(): string;
/**
* Check if this MIME type equals another
*/
public function equals(self $other): bool;
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
use App\Framework\Filesystem\ValueObjects\FilePath;
/**
* Mock MIME type detector for testing
*
* Returns a predefined MIME type instead of detecting from file content
*/
final readonly class MockMimeTypeDetector implements MimeTypeDetector
{
public function __construct(
private MimeTypeInterface $mimeType
) {
}
/**
* Create from MIME type string
*/
public static function fromString(string $mimeTypeString): self
{
$mimeType = MimeType::tryFromString($mimeTypeString)
?? CustomMimeType::fromString($mimeTypeString);
return new self($mimeType);
}
/**
* Always returns the predefined MIME type, ignoring the file path
*/
public function detect(FilePath $filePath): MimeTypeInterface
{
return $this->mimeType;
}
}

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
/**
* Request Context Value Object
*
* Encapsulates request-scoped context information for tracking and auditing.
* Used by StateHistoryManager for capturing request metadata.
*
* Immutable value object following framework patterns.
*/
final readonly class RequestContext
{
public function __construct(
private ?string $userId = null,
private ?string $sessionId = null,
private ?string $ipAddress = null,
private ?string $userAgent = null,
) {}
/**
* Create RequestContext from HttpRequest
*/
public static function fromRequest(HttpRequest $request): self
{
return new self(
userId: null, // Must be set separately after authentication
sessionId: $request->session?->getId(),
ipAddress: (string) $request->server->getRemoteAddr(),
userAgent: $request->server->getUserAgent()?->toString(),
);
}
/**
* Create new instance with userId
*/
public function withUserId(string $userId): self
{
return new self(
userId: $userId,
sessionId: $this->sessionId,
ipAddress: $this->ipAddress,
userAgent: $this->userAgent,
);
}
public function getUserId(): ?string
{
return $this->userId;
}
public function getSessionId(): ?string
{
return $this->sessionId;
}
public function getIpAddress(): ?string
{
return $this->ipAddress;
}
public function getUserAgent(): ?string
{
return $this->userAgent;
}
/**
* Check if context has user authentication
*/
public function isAuthenticated(): bool
{
return $this->userId !== null;
}
/**
* Convert to array for logging/serialization
*
* @return array{user_id?: string, session_id?: string, ip_address?: string, user_agent?: string}
*/
public function toArray(): array
{
$data = [];
if ($this->userId !== null) {
$data['user_id'] = $this->userId;
}
if ($this->sessionId !== null) {
$data['session_id'] = $this->sessionId;
}
if ($this->ipAddress !== null) {
$data['ip_address'] = $this->ipAddress;
}
if ($this->userAgent !== null) {
$data['user_agent'] = $this->userAgent;
}
return $data;
}
}

View File

@@ -46,7 +46,7 @@ final class RequestIdGenerator
$headerRequestId = $headers?->getFirst(self::REQUEST_ID_HEADER);
// Neue RequestId erstellen (validiert automatisch die Header-ID, falls vorhanden)
$this->requestId = new RequestId($headerRequestId, $this->secret);
$this->requestId = new RequestId($this->secret, $headerRequestId);
}
return $this->requestId;

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Framework\Http;
use App\Framework\Filesystem\ValueObjects\FilePath;
final readonly class UploadedFile
{
public function __construct(
@@ -12,7 +14,8 @@ final readonly class UploadedFile
public int $size,
public string $tmpName,
public UploadError $error,
private bool $skipValidation = false
private bool $skipValidation = false,
private ?MimeTypeDetector $mimeTypeDetector = null
) {
if (! $this->skipValidation && $this->isValid() === false) {
throw new \InvalidArgumentException("Invalid uploaded file: {$this->name}");
@@ -34,13 +37,16 @@ final readonly class UploadedFile
return $this->error === UploadError::OK && is_uploaded_file($this->tmpName);
}
public function getMimeType(): string
public function getMimeType(): MimeTypeInterface
{
return mime_content_type($this->tmpName);
$detector = $this->mimeTypeDetector ?? new FilesystemMimeTypeDetector();
return $detector->detect(FilePath::create($this->tmpName));
}
/**
* Create an UploadedFile for testing without validation
*
* Uses MockMimeTypeDetector to return the specified type instead of detecting from file
*/
public static function createForTesting(
string $name,
@@ -49,6 +55,14 @@ final readonly class UploadedFile
string $tmpName,
UploadError $error = UploadError::OK
): self {
return new self($name, $type, $size, $tmpName, $error, skipValidation: true);
return new self(
$name,
$type,
$size,
$tmpName,
$error,
skipValidation: true,
mimeTypeDetector: MockMimeTypeDetector::fromString($type)
);
}
}

View File

@@ -28,13 +28,13 @@ final class Handle
* Equivalent to curl_errno()
* Read-only access, privately settable
*/
public private(set) int $errorNumber = 0;
private(set) int $errorNumber = 0;
/**
* Equivalent to curl_error()
* Read-only access, privately settable
*/
public private(set) string $errorMessage = '';
private(set) string $errorMessage = '';
/**
* Underlying cURL handle resource
@@ -296,12 +296,4 @@ final class Handle
throw HandleException::invalidOptionType($opt, $value);
}
}
/**
* Cleanup: close cURL handle
*/
public function __destruct()
{
curl_close($this->handle);
}
}

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Framework\LiveComponents\Attributes;
use Attribute;
/**
* Marks a method as pollable for LiveComponents system.
*
* The method will be automatically called at specified intervals when attached to a component.
*
* Method Requirements:
* - Must be public
* - Return type must be serializable (array, SerializableState, scalar)
* - Should be idempotent (safe to call multiple times)
*
* @example Basic polling
* ```php
* #[Poll(interval: 1000)]
* public function checkNotifications(): array
* {
* return ['count' => $this->notificationService->getUnreadCount()];
* }
* ```
*
* @example With event dispatch
* ```php
* #[Poll(interval: 5000, event: 'notifications.updated')]
* public function pollNotifications(): NotificationState
* {
* return $this->notificationService->getCurrentState();
* }
* ```
*/
#[Attribute(Attribute::TARGET_METHOD)]
final readonly class Poll
{
/**
* @param int $interval Polling interval in milliseconds (minimum: 100ms)
* @param bool $enabled Whether polling is enabled by default
* @param string|null $event Optional event name to dispatch on poll
* @param bool $stopOnError Whether to stop polling if method throws exception
*/
public function __construct(
public int $interval = 1000,
public bool $enabled = true,
public ?string $event = null,
public bool $stopOnError = false
) {
if ($interval < 100) {
throw new \InvalidArgumentException('Poll interval must be at least 100ms to prevent performance issues');
}
if ($interval > 300000) {
throw new \InvalidArgumentException('Poll interval cannot exceed 5 minutes (300000ms)');
}
}
/**
* Get interval as Duration value object.
*/
public function getInterval(): \App\Framework\Core\ValueObjects\Duration
{
return \App\Framework\Core\ValueObjects\Duration::fromMilliseconds($this->interval);
}
/**
* Create new instance with different enabled state.
*/
public function withEnabled(bool $enabled): self
{
return new self(
interval: $this->interval,
enabled: $enabled,
event: $this->event,
stopOnError: $this->stopOnError
);
}
/**
* Create new instance with different interval.
*/
public function withInterval(int $interval): self
{
return new self(
interval: $interval,
enabled: $this->enabled,
event: $this->event,
stopOnError: $this->stopOnError
);
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Framework\LiveComponents\Attributes;
use Attribute;
/**
* Track State History Attribute
*
* Marks a LiveComponent to track state changes in component_state_history table.
* Use this for components where debugging, analytics, or audit trails are needed.
*
* Example:
* #[LiveComponent(name: 'order-form')]
* #[TrackStateHistory]
* final readonly class OrderFormComponent
* {
* // State changes will be tracked in history
* }
*
* Performance Impact:
* - One additional INSERT per state change
* - Minimal overhead (~1-2ms per change)
* - Consider cleanup strategy for old history entries
*/
#[Attribute(Attribute::TARGET_CLASS)]
final readonly class TrackStateHistory
{
public function __construct(
public bool $trackIpAddress = true,
public bool $trackUserAgent = true,
public bool $trackChangedProperties = true,
public ?int $maxHistoryEntries = null, // Auto-cleanup after N entries
) {}
}

View File

@@ -9,7 +9,7 @@ use App\Framework\Core\ValueObjects\Byte;
use App\Framework\Http\Enums\Method;
use App\Framework\Http\HttpRequest;
use App\Framework\LiveComponents\Services\ChunkedUploadManager;
use App\Framework\LiveComponents\Services\UploadProgressTracker;
use App\Framework\LiveComponents\Services\UploadProgressTrackerInterface;
use App\Framework\LiveComponents\ValueObjects\ChunkHash;
use App\Framework\LiveComponents\ValueObjects\UploadSessionId;
use App\Framework\Router\Result\JsonResponse;
@@ -29,7 +29,7 @@ final readonly class ChunkedUploadController
{
public function __construct(
private ChunkedUploadManager $uploadManager,
private UploadProgressTracker $progressTracker
private UploadProgressTrackerInterface $progressTracker
) {
}

Some files were not shown because too many files have changed in this diff Show More