feat: CI/CD pipeline setup complete - Ansible playbooks updated, secrets configured, workflow ready
This commit is contained in:
@@ -290,7 +290,7 @@ final class AnalyticsCollector
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->random->float() <= $this->samplingRate;
|
||||
return $this->random->float(0, 1) <= $this->samplingRate;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Framework\Cache\Strategies;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
|
||||
/**
|
||||
* Access pattern tracking for adaptive TTL calculation
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
namespace App\Framework\Cache\Strategies;
|
||||
|
||||
use App\Framework\Cache\CacheKey;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
|
||||
/**
|
||||
* Active warming job
|
||||
*/
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
namespace App\Framework\Cache\Strategies;
|
||||
|
||||
use App\Framework\Cache\CacheKey;
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
|
||||
/**
|
||||
* Warming operation result
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
95
src/Framework/Config/DockerSecretsResolver.php
Normal file
95
src/Framework/Config/DockerSecretsResolver.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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++;
|
||||
|
||||
161
src/Framework/Config/EnvFileParser.php
Normal file
161
src/Framework/Config/EnvFileParser.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
63
src/Framework/Console/Components/EventDispatcher.php
Normal file
63
src/Framework/Console/Components/EventDispatcher.php
Normal 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 = [];
|
||||
}
|
||||
}
|
||||
|
||||
242
src/Framework/Console/Components/InputParser.php
Normal file
242
src/Framework/Console/Components/InputParser.php
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
54
src/Framework/Console/Components/KeyEvent.php
Normal file
54
src/Framework/Console/Components/KeyEvent.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
117
src/Framework/Console/Components/MenuBar.php
Normal file
117
src/Framework/Console/Components/MenuBar.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
46
src/Framework/Console/Components/MouseEvent.php
Normal file
46
src/Framework/Console/Components/MouseEvent.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
84
src/Framework/Console/Result/CompositeResult.php
Normal file
84
src/Framework/Console/Result/CompositeResult.php
Normal 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;
|
||||
}
|
||||
}
|
||||
46
src/Framework/Console/Result/ConsoleResult.php
Normal file
46
src/Framework/Console/Result/ConsoleResult.php
Normal 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;
|
||||
}
|
||||
188
src/Framework/Console/Result/ConsoleResultBuilder.php
Normal file
188
src/Framework/Console/Result/ConsoleResultBuilder.php
Normal 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);
|
||||
}
|
||||
}
|
||||
91
src/Framework/Console/Result/TableResult.php
Normal file
91
src/Framework/Console/Result/TableResult.php
Normal 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();
|
||||
}
|
||||
}
|
||||
89
src/Framework/Console/Result/TextResult.php
Normal file
89
src/Framework/Console/Result/TextResult.php
Normal 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);
|
||||
}
|
||||
}
|
||||
26
src/Framework/Console/Screen/MouseControlCode.php
Normal file
26
src/Framework/Console/Screen/MouseControlCode.php
Normal 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}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -4,5 +4,11 @@ namespace App\Framework\Core\ValueObjects\PhoneNumber;
|
||||
|
||||
enum PhoneNumberFormat
|
||||
{
|
||||
case E164;
|
||||
|
||||
case INTERNATIONAL;
|
||||
|
||||
case NATIONAL;
|
||||
|
||||
case RFC3966;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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'"
|
||||
);
|
||||
|
||||
|
||||
@@ -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"
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
37
src/Framework/ErrorHandling/ErrorHandlerInitializer.php
Normal file
37
src/Framework/ErrorHandling/ErrorHandlerInitializer.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
174
src/Framework/ErrorHandling/ErrorHandlerManager.php
Normal file
174
src/Framework/ErrorHandling/ErrorHandlerManager.php
Normal 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;
|
||||
}
|
||||
}
|
||||
86
src/Framework/ErrorHandling/ErrorHandlerRegistry.php
Normal file
86
src/Framework/ErrorHandling/ErrorHandlerRegistry.php
Normal 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([]);
|
||||
}
|
||||
}
|
||||
58
src/Framework/ErrorHandling/ErrorHandlingResult.php
Normal file
58
src/Framework/ErrorHandling/ErrorHandlingResult.php
Normal 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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
52
src/Framework/ErrorHandling/Handlers/HandlerRegistration.php
Normal file
52
src/Framework/ErrorHandling/Handlers/HandlerRegistration.php
Normal 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;
|
||||
}
|
||||
}
|
||||
32
src/Framework/ErrorHandling/Handlers/HandlerResult.php
Normal file
32
src/Framework/ErrorHandling/Handlers/HandlerResult.php
Normal 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);
|
||||
}
|
||||
}
|
||||
45
src/Framework/ErrorHandling/Handlers/HttpErrorHandler.php
Normal file
45
src/Framework/ErrorHandling/Handlers/HttpErrorHandler.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
571
src/Framework/ErrorHandling/Templates/debug.view.php
Normal file
571
src/Framework/ErrorHandling/Templates/debug.view.php
Normal 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>
|
||||
@@ -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>
|
||||
118
src/Framework/ErrorHandling/Templates/production.view.php
Normal file
118
src/Framework/ErrorHandling/Templates/production.view.php
Normal 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>
|
||||
177
src/Framework/ErrorHandling/ValueObjects/ErrorMetadata.php
Normal file
177
src/Framework/ErrorHandling/ValueObjects/ErrorMetadata.php
Normal 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'] ?? []
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
106
src/Framework/ErrorHandling/fallback.html
Normal file
106
src/Framework/ErrorHandling/fallback.html
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
231
src/Framework/ErrorReporting/ErrorReportingConfig.php
Normal file
231
src/Framework/ErrorReporting/ErrorReportingConfig.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
71
src/Framework/Http/CustomMimeType.php
Normal file
71
src/Framework/Http/CustomMimeType.php
Normal 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]];
|
||||
}
|
||||
}
|
||||
35
src/Framework/Http/FilesystemMimeTypeDetector.php
Normal file
35
src/Framework/Http/FilesystemMimeTypeDetector.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
24
src/Framework/Http/MimeTypeDetector.php
Normal file
24
src/Framework/Http/MimeTypeDetector.php
Normal 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;
|
||||
}
|
||||
33
src/Framework/Http/MimeTypeInterface.php
Normal file
33
src/Framework/Http/MimeTypeInterface.php
Normal 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;
|
||||
}
|
||||
39
src/Framework/Http/MockMimeTypeDetector.php
Normal file
39
src/Framework/Http/MockMimeTypeDetector.php
Normal 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;
|
||||
}
|
||||
}
|
||||
105
src/Framework/Http/RequestContext.php
Normal file
105
src/Framework/Http/RequestContext.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
94
src/Framework/LiveComponents/Attributes/Poll.php
Normal file
94
src/Framework/LiveComponents/Attributes/Poll.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user