Enable Discovery debug logging for production troubleshooting

- Add DISCOVERY_LOG_LEVEL=debug
- Add DISCOVERY_SHOW_PROGRESS=true
- Temporary changes for debugging InitializerProcessor fixes on production
This commit is contained in:
2025-08-11 20:13:26 +02:00
parent 59fd3dd3b1
commit 55a330b223
3683 changed files with 2956207 additions and 16948 deletions

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Api;
@@ -18,7 +19,8 @@ final readonly class ClientLogController
{
public function __construct(
private DefaultLogger $logger
) {}
) {
}
#[Route(path: '/api/log', method: Method::POST)]
public function __invoke(ClientLogRequest $request): JsonResult

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Api;
use App\Framework\Http\ControllerRequest;
@@ -13,5 +15,6 @@ final readonly class ClientLogRequest implements ControllerRequest
public string $level,
public string $message,
public ?array $context = null,
) {}
) {
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Batch;
/**
* Configuration for batch processing
*/
final readonly class BatchConfig
{
public function __construct(
public int $maxOperations = 100,
public int $maxConcurrentOperations = 10,
public bool $allowContinueOnError = true,
public array $allowedMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
public int $timeoutSeconds = 30
) {
}
public static function default(): self
{
return new self();
}
public static function strict(): self
{
return new self(
maxOperations: 50,
maxConcurrentOperations: 5,
allowContinueOnError: false,
timeoutSeconds: 10
);
}
public static function permissive(): self
{
return new self(
maxOperations: 200,
maxConcurrentOperations: 20,
allowContinueOnError: true,
timeoutSeconds: 60
);
}
public function isMethodAllowed(string $method): bool
{
return in_array(strtoupper($method), $this->allowedMethods);
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Batch;
use App\Framework\Http\Method;
/**
* Represents a single operation in a batch request
*/
final readonly class BatchOperation
{
public function __construct(
public string $id,
public Method $method,
public string $path,
public array $headers = [],
public ?string $body = null,
public array $queryParams = []
) {
}
public function toArray(): array
{
return [
'id' => $this->id,
'method' => $this->method->value,
'path' => $this->path,
'headers' => $this->headers,
'body' => $this->body,
'query' => $this->queryParams,
];
}
public static function fromArray(array $data): self
{
return new self(
id: $data['id'] ?? '',
method: Method::from($data['method'] ?? 'GET'),
path: $data['path'] ?? '/',
headers: $data['headers'] ?? [],
body: $data['body'] ?? null,
queryParams: $data['query'] ?? []
);
}
}

View File

@@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Batch;
use App\Framework\Async\FiberManager;
use App\Framework\Core\Application;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException;
use App\Framework\Http\Headers;
use App\Framework\Http\HttpRequest;
use App\Framework\Http\RequestBody;
use App\Framework\Http\Status;
/**
* Processes batch requests by executing multiple operations
*/
final readonly class BatchProcessor
{
public function __construct(
private Application $application,
private FiberManager $fiberManager,
private BatchConfig $config = new BatchConfig()
) {
}
/**
* Process a batch request and return batch responses
* @return BatchResponse[]
*/
public function process(BatchRequest $batchRequest): array
{
// Validate operations against config
$this->validateOperations($batchRequest);
if ($batchRequest->count() <= $this->config->maxConcurrentOperations) {
return $this->processConcurrently($batchRequest);
}
return $this->processInChunks($batchRequest);
}
/**
* Process all operations concurrently using FiberManager
* @return BatchResponse[]
*/
private function processConcurrently(BatchRequest $batchRequest): array
{
$operations = [];
foreach ($batchRequest->operations as $operation) {
$operations[$operation->id] = fn () => $this->executeOperation($operation);
}
$results = $this->fiberManager->batch($operations);
$responses = [];
foreach ($results as $id => $result) {
if ($result instanceof BatchResponse) {
$responses[] = $result;
} elseif ($result instanceof \Throwable) {
$responses[] = BatchResponse::error(
id: $id,
status: Status::INTERNAL_SERVER_ERROR,
error: $result->getMessage()
);
if (! $batchRequest->continueOnError) {
break;
}
}
}
return $responses;
}
/**
* Process operations in chunks for large batch requests
* @return BatchResponse[]
*/
private function processInChunks(BatchRequest $batchRequest): array
{
$chunks = array_chunk($batchRequest->operations, $this->config->maxConcurrentOperations);
$responses = [];
foreach ($chunks as $chunk) {
$chunkRequest = new BatchRequest($chunk, $batchRequest->continueOnError);
$chunkResponses = $this->processConcurrently($chunkRequest);
$responses = array_merge($responses, $chunkResponses);
// Stop processing if continueOnError is false and we have an error
if (! $batchRequest->continueOnError) {
$hasError = false;
foreach ($chunkResponses as $response) {
if ($response->error !== null) {
$hasError = true;
break;
}
}
if ($hasError) {
break;
}
}
}
return $responses;
}
/**
* Execute a single batch operation
*/
private function executeOperation(BatchOperation $operation): BatchResponse
{
try {
// Create HTTP request from batch operation
$request = $this->createHttpRequest($operation);
// Execute the request through the application
$response = $this->application->handleRequest($request);
return BatchResponse::success(
id: $operation->id,
status: $response->status,
body: $response->body,
headers: $response->headers->toArray()
);
} catch (FrameworkException $e) {
return BatchResponse::error(
id: $operation->id,
status: $this->mapExceptionToStatus($e),
error: $e->getMessage()
);
} catch (\Throwable $e) {
return BatchResponse::error(
id: $operation->id,
status: Status::INTERNAL_SERVER_ERROR,
error: 'Internal server error'
);
}
}
/**
* Create HTTP request from batch operation
*/
private function createHttpRequest(BatchOperation $operation): HttpRequest
{
// Parse query string from path if present
$pathParts = parse_url($operation->path);
$path = $pathParts['path'] ?? $operation->path;
$queryString = $pathParts['query'] ?? '';
// Merge query params from path and operation
$queryParams = $operation->queryParams;
if (! empty($queryString)) {
parse_str($queryString, $parsedQuery);
$queryParams = array_merge($queryParams, $parsedQuery);
}
return new HttpRequest(
method: $operation->method,
headers: new Headers($operation->headers),
body: $operation->body ?? '',
path: $path,
queryParams: $queryParams,
parsedBody: new RequestBody(
$operation->method,
new Headers($operation->headers),
$operation->body ?? '',
[]
)
);
}
/**
* Validate operations against batch configuration
*/
private function validateOperations(BatchRequest $batchRequest): void
{
if ($batchRequest->count() > $this->config->maxOperations) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
"Batch request exceeds maximum operations limit ({$this->config->maxOperations})"
);
}
if (! $this->config->allowContinueOnError && $batchRequest->continueOnError) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
'Continue on error is not allowed by current configuration'
);
}
foreach ($batchRequest->operations as $operation) {
if (! $this->config->isMethodAllowed($operation->method->value)) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
"Method {$operation->method->value} is not allowed in batch operations"
);
}
}
}
/**
* Map framework exception to appropriate HTTP status
*/
private function mapExceptionToStatus(FrameworkException $e): Status
{
return match(true) {
$e->isCategory('VAL') => Status::BAD_REQUEST,
$e->isCategory('AUTH') => Status::UNAUTHORIZED,
$e->isErrorCode(ErrorCode::ENTITY_NOT_FOUND) => Status::NOT_FOUND,
$e->isErrorCode(ErrorCode::HTTP_RATE_LIMIT_EXCEEDED) => Status::TOO_MANY_REQUESTS,
default => Status::INTERNAL_SERVER_ERROR
};
}
}

View File

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Batch;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException;
/**
* Represents a batch request containing multiple operations
*/
final readonly class BatchRequest
{
/** @var BatchOperation[] */
public array $operations;
/**
* @param BatchOperation[] $operations
*/
public function __construct(array $operations, public bool $continueOnError = false)
{
$this->validateOperations($operations);
$this->operations = $operations;
}
public function hasOperation(string $id): bool
{
foreach ($this->operations as $operation) {
if ($operation->id === $id) {
return true;
}
}
return false;
}
public function getOperation(string $id): ?BatchOperation
{
foreach ($this->operations as $operation) {
if ($operation->id === $id) {
return $operation;
}
}
return null;
}
public function count(): int
{
return count($this->operations);
}
public function toArray(): array
{
return [
'operations' => array_map(fn ($op) => $op->toArray(), $this->operations),
'continue_on_error' => $this->continueOnError,
];
}
public static function fromArray(array $data): self
{
$operations = [];
foreach ($data['operations'] ?? [] as $operationData) {
$operations[] = BatchOperation::fromArray($operationData);
}
return new self(
operations: $operations,
continueOnError: $data['continue_on_error'] ?? false
);
}
/**
* @param BatchOperation[] $operations
*/
private function validateOperations(array $operations): void
{
if (empty($operations)) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
'Batch request must contain at least one operation'
);
}
if (count($operations) > 100) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
'Batch request cannot contain more than 100 operations'
);
}
$ids = [];
foreach ($operations as $operation) {
if (empty($operation->id)) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
'Each batch operation must have a unique ID'
);
}
if (in_array($operation->id, $ids)) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
"Duplicate operation ID found: {$operation->id}"
);
}
$ids[] = $operation->id;
}
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Batch;
use App\Framework\Http\Status;
/**
* Represents a response from a single batch operation
*/
final readonly class BatchResponse
{
public function __construct(
public string $id,
public Status $status,
public array $headers = [],
public ?string $body = null,
public ?string $error = null
) {
}
public function toArray(): array
{
return [
'id' => $this->id,
'status' => $this->status->value,
'headers' => $this->headers,
'body' => $this->body,
'error' => $this->error,
];
}
public static function success(
string $id,
Status $status,
?string $body = null,
array $headers = []
): self {
return new self(
id: $id,
status: $status,
headers: $headers,
body: $body
);
}
public static function error(
string $id,
Status $status,
string $error,
array $headers = []
): self {
return new self(
id: $id,
status: $status,
headers: $headers,
error: $error
);
}
}

View File

@@ -1,8 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
interface ControllerRequest
{
}

View File

@@ -14,7 +14,7 @@ final readonly class Cookie
public ?string $domain = '',
public bool $secure = false,
public bool $httpOnly = false,
public ?string $sameSite = 'Lax'
public SameSite $sameSite = SameSite::Lax,
) {
}
@@ -42,10 +42,13 @@ final readonly class Cookie
$cookie .= '; HttpOnly';
}
if ($this->sameSite) {
$cookie .= '; SameSite=' . $this->sameSite;
}
$cookie .= '; SameSite=' . $this->sameSite->value;
return $cookie;
}
public function __toString(): string
{
return $this->toHeaderString();
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Cookies;
enum SameSite: string

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
use App\Framework\Config\EnvKey;
use App\Framework\DI\Container;
use App\Framework\DI\Initializer;
use App\Framework\Environment\Environment;
use App\Framework\Http\Middleware\ETagMiddleware;
use App\Framework\Logging\Logger;
/**
* Initialize ETag management services
*/
final readonly class ETagInitializer
{
public function __construct(
private Environment $environment,
private Container $container
) {
}
#[Initializer]
public function createETagManager(): ETagManager
{
return new ETagManager(
enabled: $this->environment->getBool(EnvKey::ETAG_ENABLED, true),
preferWeakETags: $this->environment->getBool(EnvKey::ETAG_PREFER_WEAK, false)
);
}
#[Initializer]
public function createETagMiddleware(): ETagMiddleware
{
$etagManager = $this->container->get(ETagManager::class);
$logger = $this->container->has(Logger::class) ? $this->container->get(Logger::class) : null;
$excludePaths = $this->parseArrayEnvVar(
$this->environment->get(EnvKey::ETAG_EXCLUDE_PATHS, '/api/health,/favicon.ico,/robots.txt')
);
$excludeContentTypes = $this->parseArrayEnvVar(
$this->environment->get(EnvKey::ETAG_EXCLUDE_CONTENT_TYPES, 'application/json,text/event-stream,application/stream')
);
return ETagMiddleware::create(
etagManager: $etagManager,
logger: $logger,
options: [
'exclude_paths' => $excludePaths,
'exclude_content_types' => $excludeContentTypes,
'enabled' => $this->environment->getBool(EnvKey::ETAG_MIDDLEWARE_ENABLED, true),
]
);
}
/**
* Parse comma-separated environment variable into array
*/
private function parseArrayEnvVar(string $value): array
{
if (empty($value)) {
return [];
}
return array_map('trim', explode(',', $value));
}
}

View File

@@ -0,0 +1,261 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
use App\Framework\Http\ValueObjects\ETag;
/**
* ETag management service for HTTP caching
*/
final class ETagManager
{
public function __construct(
private readonly bool $enabled = true,
private readonly bool $preferWeakETags = false
) {
}
/**
* Generate ETag for response content
*/
public function generateETag(string $content, ?array $metadata = null): ETag
{
if (! $this->enabled) {
return ETag::strong('disabled');
}
// Include metadata in hash if provided
$hashInput = $content;
if ($metadata) {
$hashInput .= json_encode($metadata, JSON_THROW_ON_ERROR);
}
return ETag::fromContent($hashInput, $this->preferWeakETags);
}
/**
* Generate ETag from file
*/
public function generateFileETag(string $filePath): ETag
{
if (! $this->enabled) {
return ETag::strong('disabled');
}
return ETag::fromFile($filePath, $this->preferWeakETags);
}
/**
* Generate ETag from last modified time
*/
public function generateFromLastModified(\DateTimeInterface $lastModified, ?string $suffix = null): ETag
{
if (! $this->enabled) {
return ETag::strong('disabled');
}
$value = $lastModified->getTimestamp();
if ($suffix) {
$value .= '-' . $suffix;
}
$hash = hash('sha256', (string) $value);
return new ETag($hash, $this->preferWeakETags);
}
/**
* Check if request has matching ETag (for If-None-Match)
*/
public function checkIfNoneMatch(HttpRequest $request, ETag $currentETag): bool
{
if (! $this->enabled) {
return false;
}
$ifNoneMatch = $request->headers->get('If-None-Match');
if (! is_string($ifNoneMatch) || empty($ifNoneMatch)) {
return false;
}
$requestETags = ETag::parseMultiple($ifNoneMatch);
// Special case: * matches any ETag
if (in_array('*', $requestETags, true)) {
return true;
}
// Check if current ETag matches any of the request ETags
return $currentETag->matchesAny($requestETags, false); // Weak comparison for If-None-Match
}
/**
* Check if request has matching ETag (for If-Match)
*/
public function checkIfMatch(HttpRequest $request, ETag $currentETag): bool
{
if (! $this->enabled) {
return true; // If disabled, allow all requests
}
$ifMatch = $request->headers->get('If-Match');
if (! is_string($ifMatch) || empty($ifMatch)) {
return true; // No If-Match header means proceed
}
$requestETags = ETag::parseMultiple($ifMatch);
// Special case: * matches any ETag
if (in_array('*', $requestETags, true)) {
return true;
}
// Check if current ETag matches any of the request ETags (strong comparison)
return $currentETag->matchesAny($requestETags, true);
}
/**
* Add ETag header to response
*/
public function addETagToResponse(HttpResponse $response, ETag $etag): HttpResponse
{
if (! $this->enabled) {
return $response;
}
$newHeaders = $response->headers->with('ETag', $etag->toHeaderValue());
return new HttpResponse(
status: $response->status,
headers: $newHeaders,
body: $response->body
);
}
/**
* Create 304 Not Modified response
*/
public function createNotModifiedResponse(ETag $etag, ?Headers $additionalHeaders = null): HttpResponse
{
$headers = Headers::empty()
->with('ETag', $etag->toHeaderValue())
->with('Cache-Control', 'private, max-age=0');
if ($additionalHeaders) {
// Merge additional headers (Date, Last-Modified, etc.)
foreach ($additionalHeaders->toArray() as $name => $values) {
if (is_array($values)) {
foreach ($values as $value) {
$headers = $headers->with($name, $value);
}
} else {
$headers = $headers->with($name, $values);
}
}
}
return new HttpResponse(
status: 304,
headers: $headers,
body: '' // 304 responses must not have a body
);
}
/**
* Process request and response for ETag handling
*
* Returns either the original response with ETag header added,
* or a 304 Not Modified response if ETags match
*/
public function processResponse(HttpRequest $request, HttpResponse $response, ?ETag $etag = null): HttpResponse
{
if (! $this->enabled) {
return $response;
}
// Generate ETag if not provided
if (! $etag) {
$etag = $this->generateETag($response->body ?? '');
}
// Check for If-None-Match header
if ($this->checkIfNoneMatch($request, $etag)) {
return $this->createNotModifiedResponse($etag);
}
// Add ETag to response
return $this->addETagToResponse($response, $etag);
}
/**
* Validate preconditions (If-Match, If-None-Match)
*
* Returns null if preconditions pass, or HttpResponse if they fail
*/
public function validatePreconditions(HttpRequest $request, ETag $currentETag): ?HttpResponse
{
if (! $this->enabled) {
return null;
}
// Check If-Match header (for PUT, PATCH, DELETE)
$method = $request->method ?? 'GET';
if (in_array($method, ['PUT', 'PATCH', 'DELETE'], true)) {
if (! $this->checkIfMatch($request, $currentETag)) {
return new HttpResponse(
status: 412, // Precondition Failed
headers: Headers::empty()->with('ETag', $currentETag->toHeaderValue()),
body: 'Precondition Failed'
);
}
}
// Check If-None-Match for non-GET/HEAD methods
if (! in_array($method, ['GET', 'HEAD'], true)) {
if ($this->checkIfNoneMatch($request, $currentETag)) {
return new HttpResponse(
status: 412, // Precondition Failed
headers: Headers::empty()->with('ETag', $currentETag->toHeaderValue()),
body: 'Precondition Failed'
);
}
}
return null;
}
/**
* Check if ETag management is enabled
*/
public function isEnabled(): bool
{
return $this->enabled;
}
/**
* Check if weak ETags are preferred
*/
public function prefersWeakETags(): bool
{
return $this->preferWeakETags;
}
/**
* Extract ETag from response if present
*/
public function extractETagFromResponse(HttpResponse $response): ?ETag
{
$etagHeader = $response->headers->get('ETag');
if (! is_string($etagHeader) || empty($etagHeader)) {
return null;
}
try {
return ETag::parse($etagHeader);
} catch (\InvalidArgumentException) {
return null;
}
}
}

View File

@@ -11,7 +11,7 @@ final class AdaptiveStreamEmitter implements Emitter
{
public function emit(Response $response): void
{
if (!$response instanceof AdaptiveStreamResponse) {
if (! $response instanceof AdaptiveStreamResponse) {
throw new \InvalidArgumentException('Response must be AdaptiveStreamResponse');
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Emitter;
use App\Framework\Http\Response;

View File

@@ -1,19 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Emitter;
use App\Framework\Http\Emitter\Emitter;
use App\Framework\Http\Response;
final class HttpEmitter implements Emitter
{
public function emit(Response $response): void
{
// 1. Robustheit: Prüfen, ob Header bereits gesendet wurden.
if (headers_sent($file, $line)) {
throw new \RuntimeException("Kann die Antwort nicht senden: Header wurden bereits in {$file}:{$line} gesendet.");
// Log error but don't throw exception to avoid recursion
error_log("Warning: Headers already sent in {$file}:{$line}. Can only output body.");
echo $response->body;
return;
}
// 2. Robustheit: Jeglichen zuvor gepufferten Output löschen, um unbeabsichtigte Ausgaben zu vermeiden.
@@ -31,6 +34,7 @@ final class HttpEmitter implements Emitter
foreach ((array)$value as $cookie) {
header("Set-Cookie: $cookie", false);
}
// Wichtig: Mit dem nächsten Header fortfahren, um doppeltes Senden zu verhindern.
continue;
}

View File

@@ -1,18 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Emitter;
use App\Framework\Http\Emitter\Emitter;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\DateTime\Timer;
use App\Framework\Http\Response;
use App\Framework\Http\Responses\SseResponse;
use App\Framework\Http\SseStream;
final readonly class SseEmitter implements Emitter
{
public function __construct(
private Timer $timer
) {
}
public function emit(Response $response): void
{
if($response instanceof SseResponse === false) {
if ($response instanceof SseResponse === false) {
throw new \InvalidArgumentException('Response must be an instance of SseResponse');
}
@@ -34,10 +41,11 @@ final readonly class SseEmitter implements Emitter
$callback = $response->streamCallback;
// Streaming-Loop
while ($stream->isConnectionActive()) {
while ($stream->isConnectionActive() && $stream->isActive()) {
// Prüfen, ob die maximale Dauer erreicht wurde
if ($maxDuration > 0 && (time() - $startTime) >= $maxDuration) {
$stream->sendJson(['message' => 'Maximum duration reached'], 'timeout');
break;
}
@@ -49,8 +57,9 @@ final readonly class SseEmitter implements Emitter
// Fehler im Callback abfangen
$stream->sendJson([
'error' => $e->getMessage(),
'type' => get_class($e)
'type' => get_class($e),
], 'error');
break;
}
}
@@ -62,10 +71,15 @@ final readonly class SseEmitter implements Emitter
}
// Kurze Pause, um CPU-Last zu reduzieren
usleep(100_000); // 100ms
$this->timer->sleep(Duration::fromMilliseconds(100));
}
// Stream schließen
$stream->close();
if ($stream->isActive()) {
$stream->close();
}
// PHP-Ausführung beenden, um zusätzliche Ausgabe zu verhindern
exit();
}
}

View File

@@ -1,9 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Emitter;
use App\Framework\Http\Emitter\Emitter;
use App\Framework\Http\Range;
use App\Framework\Http\Response;
use App\Framework\Http\Responses\StreamResponse;
@@ -11,10 +11,9 @@ use App\Framework\Http\Status;
final class StreamEmitter implements Emitter
{
public function emit(Response $response): void
{
if (!$response instanceof StreamResponse) {
if (! $response instanceof StreamResponse) {
throw new \InvalidArgumentException('Response must be StreamResponse');
}
@@ -51,7 +50,7 @@ final class StreamEmitter implements Emitter
$this->streamRange($response->filePath, $range, $chunkSize);
}
private function streamRange(string $filePath,Range $range, int $chunkSize): void
private function streamRange(string $filePath, Range $range, int $chunkSize): void
{
// Stream öffnen und zur Start-Position springen
$handle = fopen($filePath, 'rb');
@@ -59,7 +58,7 @@ final class StreamEmitter implements Emitter
$remaining = $range->length;
while ($remaining > 0 && !feof($handle)) {
while ($remaining > 0 && ! feof($handle)) {
$readSize = min($chunkSize, $remaining);
$chunk = fread($handle, $readSize);
@@ -98,7 +97,7 @@ final class StreamEmitter implements Emitter
// Stream komplett senden
$handle = fopen($filePath, 'rb');
while (!feof($handle)) {
while (! feof($handle)) {
echo fread($handle, $chunkSize);
$this->flushOutput();

View File

@@ -1,9 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Emitter;
use App\Framework\Http\Emitter\Emitter;
use App\Framework\Http\Response;
use App\Framework\Http\Responses\WebSocketResponse;
use App\Framework\Http\WebSocketServer;
@@ -12,11 +12,12 @@ final class WebSocketEmitter implements Emitter
{
public function __construct(
private readonly WebSocketServer $webSocketServer = new WebSocketServer()
) {}
) {
}
public function emit(Response $response): void
{
if($response instanceof WebSocketResponse === false) {
if ($response instanceof WebSocketResponse === false) {
throw new \InvalidArgumentException('Response must be an instance of WebSocketResponse');
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Exception;
use App\Framework\Http\Status;

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Exception;
use App\Framework\Exception\FrameworkException;
@@ -7,7 +9,6 @@ use App\Framework\Http\Status;
final class NotFound extends FrameworkException implements HttpException
{
public \App\Framework\Http\Status $status {
get {
return Status::NOT_FOUND;

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Exceptions;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\FrameworkException;
/**
* Exception thrown when a middleware exceeds its execution time limit
*/
final class MiddlewareTimeoutException extends FrameworkException
{
public function __construct(
string $middlewareName,
float $timeout,
int $code = 0,
?\Throwable $previous = null
) {
$message = sprintf(
'Middleware "%s" exceeded its execution time limit of %.2f seconds',
$middlewareName,
$timeout
);
$context = ExceptionContext::create()
->withData([
'middleware_name' => $middlewareName,
'timeout' => $timeout,
'error_type' => 'middleware_timeout',
]);
parent::__construct($message, $context, $code, $previous);
}
}

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
/**

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
/**
@@ -26,8 +28,9 @@ final readonly class HeaderManipulator
}
// Status-Zeile (HTTP/1.1 200 OK) überspringen
if (!$statusLineFound && preg_match('/^HTTP\/\d\.\d\s+\d+/', $line)) {
if (! $statusLineFound && preg_match('/^HTTP\/\d\.\d\s+\d+/', $line)) {
$statusLineFound = true;
continue;
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
use InvalidArgumentException;
@@ -48,7 +50,7 @@ final readonly class Headers
$original = $this->normalizeName($name);
$new = $this->headers;
if (!isset($new[$key])) {
if (! isset($new[$key])) {
$new[$key] = [$original, [$value]];
} else {
$new[$key][1][] = $value;
@@ -68,6 +70,9 @@ final readonly class Headers
return new self($new);
}
/**
* @return string[]|null
*/
public function get(HeaderKey|string $name): ?array
{
$name = $this->getNameAsString($name);
@@ -75,9 +80,9 @@ final readonly class Headers
return $this->headers[strtolower($name)][1] ?? null;
}
public function getFirst(HeaderKey|string $name): ?string
public function getFirst(HeaderKey|string $name, ?string $default = null): ?string
{
return $this->get($name)[0] ?? null;
return $this->get($name)[0] ?? $default;
}
public function has(HeaderKey|string $name): bool
@@ -85,6 +90,7 @@ final readonly class Headers
$name = $this->getNameAsString($name);
return isset($this->headers[strtolower($name)]);
}
/**
@@ -96,6 +102,7 @@ final readonly class Headers
foreach ($this->headers as [$original, $values]) {
$output[$original] = $values;
}
return $output;
}
@@ -108,7 +115,7 @@ final readonly class Headers
*/
private function normalizeName(string $name): string
{
return preg_replace_callback('/(?:^|-)[a-z]/', fn($m) => strtoupper($m[0]), strtolower($name));
return preg_replace_callback('/(?:^|-)[a-z]/', fn ($m) => strtoupper($m[0]), strtolower($name));
}
public function toArray(): array
@@ -143,7 +150,7 @@ final readonly class Headers
$parsed = [];
foreach ($headers as $name => $value) {
if (!is_string($name)) {
if (! is_string($name)) {
throw new InvalidArgumentException('Header name must be a string');
}
@@ -155,7 +162,7 @@ final readonly class Headers
} elseif (is_array($value)) {
// Alle Werte als Strings validieren
foreach ($value as $val) {
if (!is_string($val)) {
if (! is_string($val)) {
throw new InvalidArgumentException('All header values must be strings: ' . print_r($headers, true));
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
interface HttpMiddleware
@@ -8,9 +10,9 @@ interface HttpMiddleware
* Verarbeitet den Middleware-Kontext und ruft den nächsten Handler in der Kette auf.
*
* @param MiddlewareContext $context Der Middleware-Kontext mit Request und optionaler Response
* @param callable $next Der nächste Handler in der Kette
* @param Next $next Der nächste Handler in der Kette
* @param RequestStateManager $stateManager
* @return MiddlewareContext Der aktualisierte Kontext
*/
public function __invoke(MiddlewareContext $context, callable $next, RequestStateManager $stateManager): MiddlewareContext;
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext;
}

View File

@@ -1,21 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
use App\Framework\DI\Container;
use App\Framework\Logging\DefaultLogger;
use App\Framework\Logging\Logger;
use Closure;
final readonly class HttpMiddlewareChain implements HttpMiddlewareNext
final readonly class HttpMiddlewareChain implements HttpMiddlewareChainInterface
{
private MiddlewareInvoker $invoker;
private DefaultLogger $logger;
private Logger $logger;
private RequestStateManager $stateManager;
public function __construct(
private array $middlewares,
private Closure $fallbackHandler,
private Container $container
) {
$this->invoker = new MiddlewareInvoker($this->container);
@@ -29,91 +30,101 @@ final readonly class HttpMiddlewareChain implements HttpMiddlewareNext
$this->stateManager = new MiddlewareStateManager()->forRequest($request);
// Middleware-Stack durchlaufen
$resultContext = $this->processMiddlewareStack($context, 0);
// Start der Middleware-Chain loggen
error_log("🚀 MIDDLEWARE CHAIN START - URI: {$request->path}, Method: {$request->method->value}, Middleware-Count: " . count($this->middlewares));
// Am Ende die Response aus dem Kontext zurückgeben
// Middleware-Stack durchlaufen
$resultContext = $this->doProcessMiddlewareStack($context, 0);
// Ende der Middleware-Chain loggen
if ($resultContext->hasResponse()) {
error_log("✅ MIDDLEWARE CHAIN COMPLETE - Final Response Status: {$resultContext->response?->status->value}");
return $resultContext->response;
}
// Warnung über fehlende Response
$this->logger->warning('Keine Response nach Middleware-Chain - verwende Fallback-Handler', [
'uri' => $resultContext->request->path,
'method' => $resultContext->request->method->value,
'middleware_count' => count($this->middlewares),
]);
// Wenn keine Response vorhanden ist, ist das ein Fehler in der Middleware-Konfiguration
error_log("❌ MIDDLEWARE CHAIN FAILED - No response created after processing all middlewares");
// Fallback-Handler aufrufen
$response = ($this->fallbackHandler)($resultContext->request);
if (!$response instanceof Response) {
throw new \RuntimeException(sprintf(
'Fallback-Handler hat keine Response zurückgegeben, sondern %s',
is_object($response) ? get_class($response) : gettype($response)
));
}
return $response;
throw new \RuntimeException(sprintf(
'Keine Response nach Middleware-Chain erstellt. Stellen Sie sicher, dass eine DefaultResponseMiddleware konfiguriert ist. URI: %s, Method: %s, Middleware-Count: %d',
$resultContext->request->path,
$resultContext->request->method->value,
count($this->middlewares)
));
}
private function processMiddlewareStack(MiddlewareContext $context, int $index): MiddlewareContext
private function doProcessMiddlewareStack(MiddlewareContext $context, int $index): MiddlewareContext
{
// Wenn das Ende der Middleware-Kette erreicht ist
if ($index >= count($this->middlewares)) {
$this->logDebug("Ende der Middleware-Kette erreicht", $context, $index);
return $context;
}
$middleware = $this->middlewares[$index];
$middlewareName = $this->getMiddlewareName($middleware);
// Start der Middleware loggen
error_log("🔄 MIDDLEWARE #{$index} START: {$middlewareName}");
// Status VOR der Middleware loggen
$this->logDebug("VOR Middleware #{$index} ({$middlewareName})", $context, $index);
// Next-Funktion erstellen, die zur nächsten Middleware weiterleitet
$next = function (MiddlewareContext $nextContext) use ($index, $middlewareName) {
// Status beim Aufruf von $next() loggen
$this->logDebug("NEXT aufgerufen in #{$index} ({$middlewareName})", $nextContext, $index);
// Zur nächsten Middleware weitergehen
$resultContext = $this->processMiddlewareStack($nextContext, $index + 1);
// Status beim Rückgabewert von $next() loggen
$this->logDebug("NEXT Rückgabe an #{$index} ({$middlewareName})", $resultContext, $index);
// Detaillierte Prüfung auf verlorene Response
if ($nextContext->hasResponse() && !$resultContext->hasResponse()) {
$this->logError("RESPONSE VERLOREN zwischen Middleware #{$index} ({$middlewareName}) und nachfolgenden Middlewares!");
// Next-Handler erstellen, der zur nächsten Middleware weiterleitet
$next = new readonly class ($this, $index, $middlewareName) implements Next {
public function __construct(
private HttpMiddlewareChain $chain,
private int $index,
private string $middlewareName
) {
}
return $resultContext;
public function __invoke(MiddlewareContext $context): MiddlewareContext
{
// Status beim Aufruf von $next() loggen
$this->chain->logDebug("NEXT aufgerufen in #{$this->index} ({$this->middlewareName})", $context, $this->index);
// Zur nächsten Middleware weitergehen
$resultContext = $this->chain->processMiddlewareStack($context, $this->index + 1);
// Status beim Rückgabewert von $next() loggen
$this->chain->logDebug("NEXT Rückgabe an #{$this->index} ({$this->middlewareName})", $resultContext, $this->index);
// Detaillierte Prüfung auf verlorene Response
if ($context->hasResponse() && ! $resultContext->hasResponse()) {
$this->chain->logError("RESPONSE VERLOREN zwischen Middleware #{$this->index} ({$this->middlewareName}) und nachfolgenden Middlewares!");
}
return $resultContext;
}
};
// Response-Status VOR der Middleware merken
$hadResponseBefore = $context->hasResponse();
$responseBeforeStatus = $hadResponseBefore ? $context->response?->status->value : null;
// Middleware mit dem Invoker ausführen
$resultContext = $this->invoker->invoke($middleware, $context, $next, $this->stateManager);
// Response-Status NACH der Middleware prüfen
$hasResponseAfter = $resultContext->hasResponse();
$responseAfterStatus = $hasResponseAfter ? $resultContext->response?->status->value : null;
// Detaillierte Response-Analyse
$this->analyzeResponseChanges(
$middlewareName,
$index,
$hadResponseBefore,
$hasResponseAfter,
$responseBeforeStatus,
$responseAfterStatus
);
// Status NACH der Middleware loggen
$this->logDebug("NACH Middleware #{$index} ({$middlewareName})", $resultContext, $index);
/*
// Prüfung ob diese Middleware eine Response hinzugefügt oder entfernt hat
if (!$context->hasResponse() && $resultContext->hasResponse()) {
$this->logInfo("✅ Response ERSTELLT von Middleware #{$index} ({$middlewareName})", [
'middleware_name' => $middlewareName,
'middleware_index' => $index,
'response_status' => $resultContext->response?->status,
]);
} elseif ($context->hasResponse() && !$resultContext->hasResponse()) {
$this->logError("❌ Response ENTFERNT von Middleware #{$index} ({$middlewareName})!", [
'middleware_name' => $middlewareName,
'middleware_index' => $index,
'original_response_status' => $context->response?->status,
]);
}
*/
return $resultContext;
}
@@ -128,32 +139,67 @@ final readonly class HttpMiddlewareChain implements HttpMiddlewareNext
if (is_object($middleware)) {
$className = get_class($middleware);
return substr($className, strrpos($className, '\\') + 1);
}
return gettype($middleware);
}
/**
* Analysiert Response-Änderungen durch eine Middleware
*/
private function analyzeResponseChanges(
string $middlewareName,
int $index,
bool $hadResponseBefore,
bool $hasResponseAfter,
?int $responseBeforeStatus,
?int $responseAfterStatus
): void {
// Response wurde erstellt
if (! $hadResponseBefore && $hasResponseAfter) {
error_log("✅ RESPONSE CREATED by Middleware #{$index} ({$middlewareName}) - Status: {$responseAfterStatus}");
return;
}
// Response wurde verloren
if ($hadResponseBefore && ! $hasResponseAfter) {
error_log("❌ RESPONSE LOST by Middleware #{$index} ({$middlewareName}) - Previous Status: {$responseBeforeStatus}");
return;
}
// Response-Status wurde geändert
if ($hadResponseBefore && $hasResponseAfter && $responseBeforeStatus !== $responseAfterStatus) {
error_log("🔄 RESPONSE MODIFIED by Middleware #{$index} ({$middlewareName}) - Status: {$responseBeforeStatus}{$responseAfterStatus}");
return;
}
// Response blieb unverändert (normal)
if ($hadResponseBefore && $hasResponseAfter && $responseBeforeStatus === $responseAfterStatus) {
error_log("➡️ RESPONSE PASSED THROUGH Middleware #{$index} ({$middlewareName}) - Status: {$responseAfterStatus}");
return;
}
// Keine Response vor und nach der Middleware (normal für frühe Middlewares)
// @phpstan-ignore booleanNot.alwaysTrue
if (! $hadResponseBefore && ! $hasResponseAfter) {
error_log("⚪ NO RESPONSE before/after Middleware #{$index} ({$middlewareName})");
}
}
/**
* Debug-Logging mit Context-Informationen
*/
private function logDebug(string $message, MiddlewareContext $context, int $index): void
public function logDebug(string $message, MiddlewareContext $context, int $index): void
{
$responseStatus = $context->hasResponse() ?
"Response✅ (Status: " . ($context->response->statusCode ?? 'unknown') . ")" :
"Response✅ (Status: " . ($context->response->status->value ?? 'unknown') . ")" :
"Response❌";
/*
$this->logger->debug('Middleware Chain: {message}', [
'message' => $message,
'response_status' => $responseStatus,
'uri' => $context->request->uri ?? 'unknown',
'method' => $context->request->method->value ?? 'unknown',
'middleware_index' => $index,
'has_response' => $context->hasResponse(),
'response_code' => $context->response?->status->value,
]);
*/
}
/**
@@ -163,19 +209,27 @@ final readonly class HttpMiddlewareChain implements HttpMiddlewareNext
{
$this->logger->info('Middleware Chain: {message}', array_merge([
'message' => $message,
'component' => 'MiddlewareChain'
'component' => 'MiddlewareChain',
], $context));
}
/**
* Makes processMiddlewareStack accessible to anonymous NextHandler class
*/
public function processMiddlewareStack(MiddlewareContext $context, int $index): MiddlewareContext
{
return $this->doProcessMiddlewareStack($context, $index);
}
/**
* Error-Logging für Probleme
*/
private function logError(string $message, array $context = []): void
public function logError(string $message, array $context = []): void
{
$this->logger->error('Middleware Chain ERROR: {message}', array_merge([
'message' => $message,
'component' => 'MiddlewareChain',
'stack_trace' => debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 5)
'stack_trace' => debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 5),
], $context));
}
}

View File

@@ -1,8 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
interface HttpMiddlewareNext
interface HttpMiddlewareChainInterface
{
public function handle(HttpRequest $request): Response;
}

View File

@@ -10,11 +10,14 @@ final readonly class HttpRequest implements Request
{
#public array $parsedBody;
public Query $query;
public function __construct(
public Method $method = Method::GET,
public Headers $headers = new Headers(),
public string $body = '',
public string $path = '',
/* @deprecated use $request->query instead */
public array $queryParams = [],
public UploadedFiles $files = new UploadedFiles([]),
public Cookies $cookies = new Cookies(),
@@ -23,11 +26,32 @@ final readonly class HttpRequest implements Request
public RequestBody $parsedBody = new RequestBody(Method::GET, new Headers(), '', [])
) {
#$this->parsedBody = new RequestBodyParser()->parse($this);
$this->query = new Query($this->queryParams);
}
// Hilfsmethode zum Abrufen von Query-Parametern
/* @deprecated use $request->query->get() instead */
public function getQuery(string $key, mixed $default = null): mixed
{
return $this->queryParams[$key] ?? $default;
}
public function getUri(): Uri
{
$scheme = $this->server->isHttps() ? 'https' : 'http';
$host = $this->server->getHttpHost() ?: 'localhost';
$port = $this->server->getServerPort();
// Build URI
$uri = $scheme . '://' . $host;
if (($scheme === 'http' && $port !== 80) || ($scheme === 'https' && $port !== 443)) {
$uri .= ':' . $port;
}
$uri .= $this->path;
if (! empty($this->queryParams)) {
$uri .= '?' . http_build_query($this->queryParams);
}
return new Uri($uri);
}
}

View File

@@ -10,5 +10,6 @@ final readonly class HttpResponse implements Response
public Status $status = Status::OK,
public Headers $headers = new Headers(),
public string $body = ''
) {}
) {
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
/**
@@ -10,14 +12,100 @@ final readonly class IpAddress
public function __construct(
public string $value
) {
if (!filter_var($value, FILTER_VALIDATE_IP)) {
if (! filter_var($value, FILTER_VALIDATE_IP)) {
throw new \InvalidArgumentException("Invalid IP address: {$value}");
}
}
// Factory Methods
public static function from(string $ip): self
{
return new self($ip);
}
public static function fromRequest(): ?self
{
$headers = [
'HTTP_CF_CONNECTING_IP', // Cloudflare
'HTTP_CLIENT_IP', // Proxy
'HTTP_X_FORWARDED_FOR', // Load balancer/proxy
'HTTP_X_FORWARDED', // Proxy
'HTTP_X_CLUSTER_CLIENT_IP', // Cluster
'HTTP_FORWARDED_FOR', // Proxy
'HTTP_FORWARDED', // Proxy
'REMOTE_ADDR', // Standard
];
foreach ($headers as $header) {
if (! empty($_SERVER[$header])) {
$ip = trim(explode(',', $_SERVER[$header])[0]);
if (self::isValid($ip)) {
return new self($ip);
}
}
}
return null;
}
public static function localhost(): self
{
return new self('127.0.0.1');
}
// Validation
public static function isValid(string $ip): bool
{
return filter_var($ip, FILTER_VALIDATE_IP) !== false;
}
// Basic checks
public function isPrivate(): bool
{
return !filter_var($this->value, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE);
return ! filter_var($this->value, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE);
}
public function isPublic(): bool
{
return ! $this->isPrivate() && ! $this->isReserved();
}
public function isReserved(): bool
{
return filter_var($this->value, FILTER_VALIDATE_IP, FILTER_FLAG_NO_RES_RANGE) === false;
}
public function isLoopback(): bool
{
if ($this->isV4()) {
return str_starts_with($this->value, '127.');
}
return $this->value === '::1';
}
/**
* Check if IP is considered local (private or loopback)
*/
public function isLocal(): bool
{
return $this->isPrivate() || $this->isLoopback();
}
public function isV4(): bool
{
return filter_var($this->value, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false;
}
public function isV6(): bool
{
return filter_var($this->value, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
}
// Comparison
public function equals(IpAddress $other): bool
{
return $this->value === $other->value;
}
public function __toString(): string

View File

@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Metrics;
/**
* Value Object für Middleware-Metriken
* Immutable und typsicher
*/
final readonly class MiddlewareMetrics
{
public function __construct(
public int $successfulExecutions = 0,
public int $failedExecutions = 0,
public float $totalExecutionTime = 0.0,
public float $minExecutionTime = PHP_FLOAT_MAX,
public float $maxExecutionTime = 0.0,
public int $lastExecutionTime = 0,
public array $errors = []
) {
}
/**
* Gibt die Gesamtzahl der Ausführungen zurück
*/
public function getTotalExecutions(): int
{
return $this->successfulExecutions + $this->failedExecutions;
}
/**
* Berechnet die durchschnittliche Ausführungszeit
*/
public function getAverageExecutionTime(): float
{
$total = $this->getTotalExecutions();
return $total > 0 ? $this->totalExecutionTime / $total : 0.0;
}
/**
* Berechnet die Erfolgsrate in Prozent
*/
public function getSuccessRate(): float
{
$total = $this->getTotalExecutions();
return $total > 0 ? ($this->successfulExecutions / $total) * 100 : 0.0;
}
/**
* Erstellt neue Metrik-Instanz mit einer weiteren Ausführung
* (immutable pattern)
*/
public function recordExecution(float $executionTime, bool $success, ?string $errorType = null): self
{
$newErrors = $this->errors;
// Fehlertyp hinzufügen, wenn Ausführung fehlgeschlagen ist
if (! $success && $errorType) {
$newErrors[$errorType] = ($newErrors[$errorType] ?? 0) + 1;
}
return new self(
successfulExecutions: $success ? $this->successfulExecutions + 1 : $this->successfulExecutions,
failedExecutions: $success ? $this->failedExecutions : $this->failedExecutions + 1,
totalExecutionTime: $this->totalExecutionTime + $executionTime,
minExecutionTime: min($this->minExecutionTime, $executionTime),
maxExecutionTime: max($this->maxExecutionTime, $executionTime),
lastExecutionTime: time(),
errors: $newErrors
);
}
/**
* Konvertiert das Value Object in ein Array
*/
public function toArray(): array
{
return [
'successful_executions' => $this->successfulExecutions,
'failed_executions' => $this->failedExecutions,
'total_executions' => $this->getTotalExecutions(),
'total_execution_time' => $this->totalExecutionTime,
'min_execution_time' => $this->minExecutionTime === PHP_FLOAT_MAX ? 0.0 : $this->minExecutionTime,
'max_execution_time' => $this->maxExecutionTime,
'avg_execution_time' => $this->getAverageExecutionTime(),
'success_rate' => $this->getSuccessRate(),
'last_execution_time' => $this->lastExecutionTime,
'errors' => $this->errors,
];
}
/**
* Erstellt ein Value Object aus einem Array
*/
public static function fromArray(array $data): self
{
return new self(
successfulExecutions: $data['successful_executions'] ?? 0,
failedExecutions: $data['failed_executions'] ?? 0,
totalExecutionTime: $data['total_execution_time'] ?? 0.0,
minExecutionTime: $data['min_execution_time'] ?? PHP_FLOAT_MAX,
maxExecutionTime: $data['max_execution_time'] ?? 0.0,
lastExecutionTime: $data['last_execution_time'] ?? 0,
errors: $data['errors'] ?? []
);
}
/**
* Konvertiert das Value Object in einen JSON-String
*/
public function toJson(): string
{
return json_encode($this->toArray(), JSON_THROW_ON_ERROR);
}
/**
* Erstellt ein Value Object aus einem JSON-String
*/
public static function fromJson(string $json): self
{
$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
return self::fromArray($data);
}
}

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Metrics;
use App\Framework\Cache\Cache;
use App\Framework\Cache\CacheItem;
use App\Framework\Cache\CacheKey;
use App\Framework\Core\ValueObjects\Duration;
use JsonException;
/**
* Collects and reports metrics about middleware execution
*/
final readonly class MiddlewareMetricsCollector
{
/**
* @param Cache $cache Cache for storing metrics
* @param int $metricsRetentionTime Time in seconds to keep metrics in cache
*/
public function __construct(
private Cache $cache,
private int $metricsRetentionTime = 86400 // 24 hours
) {
}
/**
* Record middleware execution metrics
*
* @param string $middlewareName The fully qualified class name of the middleware
* @param float $executionTime Execution time in seconds
* @param bool $success Whether the execution was successful
* @param string|null $errorType Type of error if execution failed
* @throws JsonException
*/
public function recordExecution(
string $middlewareName,
float $executionTime,
bool $success = true,
?string $errorType = null
): void {
$cacheKey = $this->getCacheKey($middlewareName);
$metricsItem = $this->cache->get($cacheKey);
// Lade existierende Metrics oder erstelle neue
if (! $metricsItem->isHit) {
$metrics = new MiddlewareMetrics(
minExecutionTime: $executionTime,
maxExecutionTime: $executionTime,
lastExecutionTime: time()
);
} else {
try {
// Versuche, die Metrics aus dem Cache zu deserialisieren
$metrics = MiddlewareMetrics::fromJson($metricsItem->value);
} catch (\Throwable) {
// Fallback bei korrupten Daten
$metrics = new MiddlewareMetrics(
minExecutionTime: $executionTime,
maxExecutionTime: $executionTime,
lastExecutionTime: time()
);
}
}
// Update Metrics (immutable)
$updatedMetrics = $metrics->recordExecution($executionTime, $success, $errorType);
// Speichere aktualisierte Metrics
$this->cache->set(CacheItem::forSet($cacheKey, $updatedMetrics->toJson(), Duration::fromSeconds($this->metricsRetentionTime)));
}
/**
* Get metrics for a middleware
*
* @param string $middlewareName The fully qualified class name of the middleware
* @return MiddlewareMetrics|null Metrics or null if no metrics are available
*/
public function getMetrics(string $middlewareName): ?MiddlewareMetrics
{
$cacheKey = $this->getCacheKey($middlewareName);
$metricsItem = $this->cache->get($cacheKey);
if (! $metricsItem->isHit) {
return null;
}
try {
return MiddlewareMetrics::fromJson($metricsItem->value);
} catch (\Throwable) {
return null;
}
}
/**
* Get metrics for all middlewares
*
* @return array<string, MiddlewareMetrics> Metrics for all middlewares
*/
public function getAllMetrics(): array
{
$keys = $this->cache->keys('middleware_metrics:*');
$result = [];
foreach ($keys as $key) {
$middlewareName = substr($key, strlen('middleware_metrics:'));
$metrics = $this->getMetrics($middlewareName);
if ($metrics) {
$result[$middlewareName] = $metrics;
}
}
return $result;
}
/**
* Get metrics for all middlewares as array
*
* @return array<string, array> Metrics for all middlewares
*/
public function getAllMetricsAsArray(): array
{
$metrics = $this->getAllMetrics();
$result = [];
foreach ($metrics as $name => $metricObject) {
$result[$name] = $metricObject->toArray();
}
return $result;
}
/**
* Get the cache key for a middleware
*
* @param string $middlewareName The fully qualified class name of the middleware
* @return CacheKey The cache key
*/
private function getCacheKey(string $middlewareName): CacheKey
{
return CacheKey::fromString('middleware_metrics:' . $middlewareName);
}
}

View File

@@ -0,0 +1,214 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Middleware;
use App\Framework\Http\ETagManager;
use App\Framework\Http\Headers;
use App\Framework\Http\HttpMiddleware;
use App\Framework\Http\HttpResponse;
use App\Framework\Http\MiddlewareContext;
use App\Framework\Http\MiddlewarePriority;
use App\Framework\Http\MiddlewarePriorityAttribute;
use App\Framework\Http\Next;
use App\Framework\Http\RequestStateManager;
use App\Framework\Http\ValueObjects\ETag;
use App\Framework\Logging\Logger;
/**
* ETag middleware for HTTP caching support
*
* Handles ETag generation, If-None-Match/If-Match headers, and 304 Not Modified responses
*/
#[MiddlewarePriorityAttribute(MiddlewarePriority::LATE)]
final readonly class ETagMiddleware implements HttpMiddleware
{
public function __construct(
private ETagManager $etagManager,
private ?Logger $logger = null,
private array $excludePaths = ['/api/health', '/favicon.ico'],
private array $excludeContentTypes = ['application/json', 'text/event-stream'],
private bool $enabled = true
) {
}
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
{
if (! $this->enabled || ! $this->etagManager->isEnabled()) {
return $next($context);
}
$request = $context->request;
// Skip for excluded paths
$path = $request->path ?? '/';
if ($this->isPathExcluded($path)) {
return $next($context);
}
// Only handle GET and HEAD requests for caching
$method = $request->method ?? 'GET';
if (! in_array($method, ['GET', 'HEAD'], true)) {
return $this->handleNonCacheableRequest($context, $next);
}
// Execute request
$context = $next($context);
$response = $context->response;
if (! $response instanceof HttpResponse) {
return $context;
}
// Skip for excluded content types
if ($this->isContentTypeExcluded($response)) {
return $context;
}
// Process ETag for the response
return $this->processETagResponse($context, $request, $response);
}
/**
* Handle non-cacheable requests (PUT, POST, DELETE, etc.)
*/
private function handleNonCacheableRequest(MiddlewareContext $context, Next $next): MiddlewareContext
{
$request = $context->request;
// For modification requests, we might want to validate preconditions
$method = $request->method ?? 'GET';
if (in_array($method, ['PUT', 'PATCH', 'DELETE'], true)) {
// Check if there's an existing ETag we should validate against
$ifMatch = $request->headers->get('If-Match');
if (is_string($ifMatch) && ! empty($ifMatch)) {
// Note: In a real implementation, you'd need to determine the current ETag
// of the resource being modified. This would typically involve:
// 1. Extracting resource ID from the request path
// 2. Loading the resource and generating its current ETag
// 3. Validating against the If-Match header
//
// For now, we'll continue with the request and let the controller handle it
$this->logger?->debug('ETag precondition validation needed for modification request', [
'method' => $method,
'path' => $request->path ?? '/',
'if_match' => $ifMatch,
]);
}
}
return $next($context);
}
/**
* Process ETag for GET/HEAD responses
*/
private function processETagResponse(MiddlewareContext $context, mixed $request, HttpResponse $response): MiddlewareContext
{
try {
// Generate ETag from response content
$content = $response->body ?? '';
$etag = $this->etagManager->generateETag($content, [
'content_type' => $response->headers->get('Content-Type', 'text/html'),
'content_length' => strlen($content),
]);
// Check for If-None-Match header
if ($this->etagManager->checkIfNoneMatch($request, $etag)) {
$this->logger?->info('ETag matched, returning 304 Not Modified', [
'etag' => $etag->toString(),
'path' => $request->path ?? '/',
]);
// Create 304 response with additional headers
$additionalHeaders = Headers::empty();
// Preserve certain headers from the original response
$preserveHeaders = ['Date', 'Last-Modified', 'Cache-Control', 'Vary'];
foreach ($preserveHeaders as $headerName) {
$headerValue = $response->headers->get($headerName);
if (is_string($headerValue) && ! empty($headerValue)) {
$additionalHeaders = $additionalHeaders->with($headerName, $headerValue);
}
}
$notModifiedResponse = $this->etagManager->createNotModifiedResponse($etag, $additionalHeaders);
return $context->withResponse($notModifiedResponse);
}
// Add ETag to successful response
$responseWithETag = $this->etagManager->addETagToResponse($response, $etag);
$this->logger?->debug('ETag added to response', [
'etag' => $etag->toString(),
'path' => $request->path ?? '/',
'content_length' => strlen($content),
'is_weak' => $etag->isWeak(),
]);
return $context->withResponse($responseWithETag);
} catch (\Exception $e) {
$this->logger?->warning('ETag processing failed', [
'error' => $e->getMessage(),
'path' => $request->path ?? '/',
]);
// Return original response on error
return $context;
}
}
/**
* Check if path should be excluded from ETag processing
*/
private function isPathExcluded(string $path): bool
{
foreach ($this->excludePaths as $excludePath) {
if (str_starts_with($path, $excludePath)) {
return true;
}
}
return false;
}
/**
* Check if content type should be excluded from ETag processing
*/
private function isContentTypeExcluded(HttpResponse $response): bool
{
$contentType = $response->headers->get('Content-Type', '');
if (! is_string($contentType)) {
return false;
}
foreach ($this->excludeContentTypes as $excludeType) {
if (str_contains($contentType, $excludeType)) {
return true;
}
}
return false;
}
/**
* Create middleware with custom configuration
*/
public static function create(
ETagManager $etagManager,
?Logger $logger = null,
array $options = []
): self {
return new self(
etagManager: $etagManager,
logger: $logger,
excludePaths: $options['exclude_paths'] ?? ['/api/health', '/favicon.ico'],
excludeContentTypes: $options['exclude_content_types'] ?? ['application/json', 'text/event-stream'],
enabled: $options['enabled'] ?? true
);
}
}

View File

@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
use App\Framework\CircuitBreaker\CircuitBreaker;
use App\Framework\CircuitBreaker\CircuitBreakerConfig;
use App\Framework\CircuitBreaker\CircuitState;
use App\Framework\Core\ValueObjects\Duration;
use RuntimeException;
use Throwable;
/**
* Circuit breaker for middlewares to prevent repeatedly failing middlewares from being executed
*
* This class is a wrapper around the CircuitBreaker module that provides middleware-specific functionality.
*/
final class MiddlewareCircuitBreaker
{
/**
* Default configuration for middleware circuit breakers
*/
private CircuitBreakerConfig $defaultConfig;
/**
* @param CircuitBreaker $circuitBreaker The circuit breaker instance
* @param int $failureThreshold Number of failures before opening the circuit
* @param int $recoveryTimeoutSeconds Time in seconds to keep the circuit open
*/
public function __construct(
private readonly CircuitBreaker $circuitBreaker,
int $failureThreshold = 5,
int $recoveryTimeoutSeconds = 60
) {
$this->defaultConfig = new CircuitBreakerConfig(
failureThreshold: $failureThreshold,
recoveryTimeout: Duration::fromSeconds($recoveryTimeoutSeconds),
halfOpenMaxAttempts: 3,
successThreshold: 2
);
}
/**
* Check if a middleware is allowed to execute
*
* @param string $middlewareName The fully qualified class name of the middleware
* @return bool True if the middleware is allowed to execute, false otherwise
*/
public function isAllowed(string $middlewareName): bool
{
try {
// Use the CircuitBreaker to check if the middleware is allowed to execute
$this->circuitBreaker->check($this->getServiceName($middlewareName), $this->defaultConfig);
return true;
} catch (Throwable $e) {
// If the circuit is open, the check method will throw an exception
return false;
}
}
/**
* Record a successful middleware execution
*
* @param string $middlewareName The fully qualified class name of the middleware
*/
public function recordSuccess(string $middlewareName): void
{
// Delegate to the CircuitBreaker
$this->circuitBreaker->recordSuccess($this->getServiceName($middlewareName), $this->defaultConfig);
}
/**
* Record a middleware failure
*
* @param string $middlewareName The fully qualified class name of the middleware
* @param int|null $failureThreshold Custom failure threshold for this middleware
* @param int|null $openTime Custom open time for this middleware
*/
public function recordFailure(string $middlewareName, ?int $failureThreshold = null, ?int $openTime = null): void
{
// Create a custom config if custom thresholds are provided
$config = $this->defaultConfig;
if ($failureThreshold !== null || $openTime !== null) {
$config = new CircuitBreakerConfig(
failureThreshold: $failureThreshold ?? $this->defaultConfig->failureThreshold,
recoveryTimeout: $openTime ? Duration::fromSeconds($openTime) : $this->defaultConfig->recoveryTimeout,
halfOpenMaxAttempts: $this->defaultConfig->halfOpenMaxAttempts,
successThreshold: $this->defaultConfig->successThreshold
);
}
// Create a RuntimeException to pass to the CircuitBreaker
$exception = new RuntimeException("Middleware {$middlewareName} failed");
// Delegate to the CircuitBreaker
$this->circuitBreaker->recordFailure($this->getServiceName($middlewareName), $exception, $config);
}
/**
* Get the state of the circuit for a middleware
*
* @param string $middlewareName The fully qualified class name of the middleware
* @return CircuitState The current state of the circuit
*/
public function getState(string $middlewareName): CircuitState
{
return $this->circuitBreaker->getState($this->getServiceName($middlewareName), $this->defaultConfig);
}
/**
* Reset the circuit for a middleware
*
* @param string $middlewareName The fully qualified class name of the middleware
*/
public function reset(string $middlewareName): void
{
$this->circuitBreaker->reset($this->getServiceName($middlewareName));
}
/**
* Get metrics for a middleware
*
* @param string $middlewareName The fully qualified class name of the middleware
* @return array Metrics for the middleware
*/
public function getMetrics(string $middlewareName): array
{
return $this->circuitBreaker->getMetrics($this->getServiceName($middlewareName));
}
/**
* Convert a middleware name to a service name for the CircuitBreaker
*
* @param string $middlewareName The fully qualified class name of the middleware
* @return string The service name for the CircuitBreaker
*/
private function getServiceName(string $middlewareName): string
{
return 'middleware:' . $middlewareName;
}
}

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
@@ -12,7 +13,8 @@ final readonly class MiddlewareContext
public function __construct(
public Request $request,
public ?Response $response = null,
) {}
) {
}
/**
* Erstellt einen neuen Kontext mit der gleichen Anfrage, aber einer anderen Antwort

View File

@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\FrameworkException;
/**
* Exception thrown when middleware dependency resolution fails
*/
final class MiddlewareDependencyException extends FrameworkException
{
/**
* @param array<ClassName> $missingDependencies
* @param array<string, mixed> $middlewareContext
*/
public function __construct(
string $message,
private readonly ClassName $middlewareClass,
private readonly array $missingDependencies = [],
private readonly array $middlewareContext = []
) {
$exceptionContext = ExceptionContext::forOperation('middleware.dependency', 'http')
->withData([
'middleware_class' => $middlewareClass->toString(),
'missing_dependencies' => array_map(fn ($dep) => $dep->toString(), $missingDependencies),
'context' => $this->middlewareContext,
]);
parent::__construct($message, $exceptionContext);
}
public function getMiddlewareClass(): ClassName
{
return $this->middlewareClass;
}
/**
* @return array<ClassName>
*/
public function getMissingDependencies(): array
{
return $this->missingDependencies;
}
/**
* @return array<string, mixed>
*/
public function getMiddlewareContext(): array
{
return $this->middlewareContext;
}
/**
* @param array<ClassName> $missingDeps
*/
public static function missingDependencies(ClassName $middlewareClass, array $missingDeps): self
{
$depNames = array_map(fn (ClassName $dep) => $dep->getShortName(), $missingDeps);
$message = sprintf(
'Middleware %s has missing dependencies: %s',
$middlewareClass->getShortName(),
implode(', ', $depNames)
);
return new self($message, $middlewareClass, $missingDeps);
}
public static function cannotInstantiate(ClassName $middlewareClass, string $reason): self
{
$message = sprintf(
'Cannot instantiate middleware %s: %s',
$middlewareClass->getShortName(),
$reason
);
return new self($message, $middlewareClass, [], ['reason' => $reason]);
}
/**
* @param array<ClassName> $cycle
*/
public static function circularDependency(ClassName $middlewareClass, array $cycle): self
{
$cycleNames = array_map(fn (ClassName $class) => $class->getShortName(), $cycle);
$message = sprintf(
'Circular dependency detected for middleware %s: %s',
$middlewareClass->getShortName(),
implode(' → ', $cycleNames)
);
return new self($message, $middlewareClass, [], ['cycle' => $cycle]);
}
public static function classNotFound(ClassName $middlewareClass): self
{
$message = sprintf(
'Middleware class %s not found (Namespace: %s)',
$middlewareClass->getShortName(),
$middlewareClass->getNamespace() ?: 'global'
);
return new self($message, $middlewareClass);
}
public static function interfaceNotImplemented(ClassName $middlewareClass, ClassName $requiredInterface): self
{
$message = sprintf(
'Middleware %s does not implement required interface %s',
$middlewareClass->getShortName(),
$requiredInterface->getShortName()
);
return new self($message, $middlewareClass, [], ['required_interface' => $requiredInterface]);
}
}

View File

@@ -0,0 +1,659 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\DI\Container;
use App\Framework\Logging\Logger;
use App\Framework\Reflection\ReflectionProvider;
/**
* Resolves middleware dependencies by analyzing constructor parameters
* and automatically sorting middlewares in the correct execution order.
*
* Enhanced with robustness features:
* - ClassName value objects for type safety
* - Fallback strategies for critical middlewares
* - Comprehensive error handling and logging
* - Protection for essential middlewares
*/
final readonly class MiddlewareDependencyResolver
{
/** @var array<string> Critical middlewares that must never be filtered out */
private const array CRITICAL_MIDDLEWARES = [
#'App\\Framework\\Http\\Middlewares\\DefaultResponseMiddleware',
'App\\Framework\\Http\\Middlewares\\ExceptionHandlingMiddleware',
'App\\Framework\\Http\\Middlewares\\RequestIdMiddleware',
'App\\Framework\\Http\\Middlewares\\RoutingMiddleware',
];
public function __construct(
private ReflectionProvider $reflectionProvider,
private Container $container,
private Logger $logger
) {
}
/**
* Resolve middleware dependencies and return them in correct execution order
*
* @param array<string> $middlewareClasses
* @throws MiddlewareDependencyException
*/
public function resolve(array $middlewareClasses): ResolvedMiddlewareStack
{
try {
$classNames = $this->normalizeClassNames($middlewareClasses);
$this->logger->debug("Starting resolution for " . count($classNames) . " middlewares");
$dependencyGraph = $this->buildDependencyGraph($classNames);
$simplifiedGraph = $this->extractDependenciesForSort($dependencyGraph);
$sortedClasses = $this->topologicalSort($simplifiedGraph);
$availableMiddlewares = $this->filterAvailableWithFallbacks($sortedClasses);
$this->validateCriticalMiddlewares($availableMiddlewares);
$this->logger->info("Resolution completed with " . count($availableMiddlewares) . " middlewares");
return new ResolvedMiddlewareStack($availableMiddlewares, $dependencyGraph);
} catch (MiddlewareDependencyException $e) {
throw $e;
} catch (\Throwable $e) {
$this->logger->critical("Critical error during resolution: " . $e->getMessage());
throw MiddlewareDependencyException::cannotInstantiate(
ClassName::create('Unknown'),
"Dependency resolution failed: " . $e->getMessage()
);
}
}
/**
* Normalize class names to ClassName value objects
*
* @param array<string> $middlewareClasses
* @return array<ClassName>
*/
private function normalizeClassNames(array $middlewareClasses): array
{
$classNames = [];
foreach ($middlewareClasses as $middlewareClass) {
try {
$className = ClassName::create($middlewareClass);
if ($className->exists()) {
$classNames[] = $className;
} else {
$this->logger->warning("Class not found: {$className->getShortName()}");
}
} catch (\InvalidArgumentException $e) {
$this->logger->warning("Invalid class name: {$middlewareClass} - " . $e->getMessage());
}
}
return $classNames;
}
/**
* Build dependency graph by analyzing constructor parameters
*
* @param array<ClassName> $middlewareClasses
* @return array<string, array{dependencies: array<string>, services: array<string>, provides: array<string>, optional: array<string>, is_critical: bool}>
*/
private function buildDependencyGraph(array $middlewareClasses): array
{
$graph = [];
foreach ($middlewareClasses as $className) {
$classNameString = $className->getFullyQualified();
try {
$graph[$classNameString] = [
'dependencies' => $this->getMiddlewareDependencies($className),
'services' => $this->getServiceDependencies($className),
'provides' => $this->getProvidedServices($className),
'optional' => $this->getOptionalDependencies($className),
'is_critical' => $this->isCriticalMiddleware($className),
];
} catch (\Throwable $e) {
$this->logger->error("Error analyzing {$className->getShortName()}: " . $e->getMessage());
// Add minimal entry for critical middlewares even if analysis fails
if ($this->isCriticalMiddleware($className)) {
$graph[$classNameString] = [
'dependencies' => [],
'services' => [],
'provides' => [],
'optional' => [],
'is_critical' => true,
];
}
}
}
return $graph;
}
/**
* Extract dependencies for topological sorting
* @param array<string, array{dependencies: array<string>, services: array<string>, provides: array<string>, optional: array<string>, is_critical: bool}> $dependencyGraph
* @return array<string, array<string>>
*/
private function extractDependenciesForSort(array $dependencyGraph): array
{
$simplifiedGraph = [];
foreach ($dependencyGraph as $className => $data) {
$simplifiedGraph[$className] = $data['dependencies'];
}
return $simplifiedGraph;
}
/**
* Check if middleware is critical and must never be filtered out
*/
private function isCriticalMiddleware(ClassName $className): bool
{
return in_array($className->getFullyQualified(), self::CRITICAL_MIDDLEWARES, true);
}
/**
* Get middleware dependencies from constructor parameters
* @return array<string>
*/
private function getMiddlewareDependencies(ClassName $middlewareClass): array
{
try {
if (! $middlewareClass->exists()) {
return [];
}
$dependencies = [];
$parameterCollection = $this->reflectionProvider->getMethodParameters(
$middlewareClass,
'__construct'
);
foreach ($parameterCollection as $parameter) {
if ($parameter->isBuiltin()) {
continue;
}
$typeName = $parameter->getTypeName();
if (! $typeName) {
continue;
}
$dependencyClass = ClassName::create($typeName);
// Check if this parameter is another middleware
if ($this->isMiddleware($dependencyClass)) {
$dependencies[] = $dependencyClass->getFullyQualified();
}
}
return $dependencies;
} catch (\Throwable $e) {
$this->logger->error("Error analyzing {$middlewareClass->getShortName()}: " . $e->getMessage());
return [];
}
}
/**
* Get non-middleware service dependencies
* @return array<string>
*/
private function getServiceDependencies(ClassName $middlewareClass): array
{
try {
if (! $middlewareClass->exists()) {
return [];
}
$services = [];
$parameterCollection = $this->reflectionProvider->getMethodParameters(
$middlewareClass,
'__construct'
);
foreach ($parameterCollection as $parameter) {
if ($parameter->isBuiltin()) {
continue;
}
$typeName = $parameter->getTypeName();
if (! $typeName) {
continue;
}
$serviceClass = ClassName::create($typeName);
// Include non-middleware services
if (! $this->isMiddleware($serviceClass)) {
$services[] = $serviceClass->getFullyQualified();
}
}
return $services;
} catch (\Throwable $e) {
$this->logger->error("Error analyzing services for {$middlewareClass->getShortName()}: " . $e->getMessage());
return [];
}
}
/**
* Check what services this middleware provides (for future use)
*/
/**
* @return array<string>
*/
private function getProvidedServices(ClassName $middlewareClass): array
{
// This could be extended with attributes like #[Provides(['session', 'auth'])]
$provides = [];
$className = $middlewareClass->getShortName();
// Basic heuristics based on class name
if (str_contains($className, 'Session')) {
$provides[] = 'session';
}
if (str_contains($className, 'Auth')) {
$provides[] = 'authentication';
}
if (str_contains($className, 'Csrf')) {
$provides[] = 'csrf_protection';
}
if (str_contains($className, 'Response')) {
$provides[] = 'response_generation';
}
if (str_contains($className, 'Routing')) {
$provides[] = 'routing';
}
return $provides;
}
/**
* Get optional dependencies (non-required)
*/
/**
* @return array<string>
*/
private function getOptionalDependencies(ClassName $middlewareClass): array
{
try {
if (! $middlewareClass->exists()) {
return [];
}
$optional = [];
$parameterCollection = $this->reflectionProvider->getMethodParameters(
$middlewareClass,
'__construct'
);
foreach ($parameterCollection as $parameter) {
if ($parameter->isOptional()) {
if (! $parameter->isBuiltin()) {
$typeName = $parameter->getTypeName();
if ($typeName) {
$optional[] = $typeName;
}
}
}
}
return $optional;
} catch (\Throwable $e) {
$this->logger->error("Error analyzing optional dependencies for {$middlewareClass->getShortName()}: " . $e->getMessage());
return [];
}
}
/**
* Check if a class is a middleware
*/
private function isMiddleware(ClassName $className): bool
{
try {
if (! $className->exists()) {
return false;
}
return $this->reflectionProvider->implementsInterface(
$className,
HttpMiddleware::class
);
} catch (\Throwable $e) {
$this->logger->error("Error checking middleware interface for {$className->getShortName()}: " . $e->getMessage());
return false;
}
}
/**
* Topological sort to determine execution order
*/
/**
* @param array<string, array<string>> $graph
* @return array<string>
*/
private function topologicalSort(array $graph): array
{
$sorted = [];
$visited = [];
$visiting = [];
foreach (array_keys($graph) as $node) {
if (! isset($visited[$node])) {
$result = $this->topologicalSortVisit($node, $graph, $visited, $visiting, $sorted);
$visited = $result['visited'];
$visiting = $result['visiting'];
$sorted = $result['sorted'];
}
}
return array_reverse($sorted);
}
/**
* Recursive topological sort visit (returns result instead of using references)
* @param array<string, array<string>> $graph
* @param array<string, bool> $visited
* @param array<string, bool> $visiting
* @param array<string> $sorted
* @return array{visited: array<string, bool>, visiting: array<string, bool>, sorted: array<string>}
*/
private function topologicalSortVisit(string $node, array $graph, array $visited, array $visiting, array $sorted): array
{
if (isset($visiting[$node])) {
// Circular dependency detected
$this->logger->warning("Circular dependency detected involving {$node}");
return ['visited' => $visited, 'visiting' => $visiting, 'sorted' => $sorted];
}
if (isset($visited[$node])) {
return ['visited' => $visited, 'visiting' => $visiting, 'sorted' => $sorted];
}
$visiting[$node] = true;
// Visit dependencies first
foreach ($graph[$node] as $dependency) {
if (isset($graph[$dependency])) {
$result = $this->topologicalSortVisit($dependency, $graph, $visited, $visiting, $sorted);
$visited = $result['visited'];
$visiting = $result['visiting'];
$sorted = $result['sorted'];
}
}
unset($visiting[$node]);
$visited[$node] = true;
$sorted[] = $node;
return ['visited' => $visited, 'visiting' => $visiting, 'sorted' => $sorted];
}
/**
* Filter out middlewares that can't be instantiated with fallback strategies
* @param array<string> $middlewareClasses
* @return array<string>
*/
private function filterAvailableWithFallbacks(array $middlewareClasses): array
{
$available = [];
$filtered = [];
foreach ($middlewareClasses as $middlewareClass) {
$className = ClassName::create($middlewareClass);
if ($this->canInstantiate($className)) {
$available[] = $middlewareClass;
} else {
$filtered[] = $className;
// Critical middlewares get special treatment
if ($this->isCriticalMiddleware($className)) {
// debug: CRITICAL - Cannot instantiate {$className->getShortName()}, applying fallback strategy
// Try fallback strategies for critical middlewares
if ($this->attemptFallbackInstantiation($className)) {
$available[] = $middlewareClass;
// debug: SUCCESS - Fallback strategy worked for {$className->getShortName()}
} else {
// debug: FAILURE - All fallback strategies failed for {$className->getShortName()}
}
} else {
// debug: Cannot instantiate {$className->getShortName()}, skipping (not critical)
}
}
}
if (! empty($filtered)) {
$filteredNames = array_map(fn (ClassName $c) => $c->getShortName(), $filtered);
$this->logger->warning("Filtered out " . count($filtered) . " middlewares: " . implode(', ', $filteredNames));
}
return $available;
}
/**
* Attempt fallback instantiation strategies for critical middlewares
*/
private function attemptFallbackInstantiation(ClassName $className): bool
{
try {
// Strategy 1: Try basic instantiation without dependency checking
if ($this->reflectionProvider->isInstantiable($className)) {
$this->logger->debug("Fallback strategy 1 (basic instantiation) succeeded for {$className->getShortName()}");
return true;
}
// Strategy 2: Check if we can provide minimal dependencies
$parameterCollection = $this->reflectionProvider->getMethodParameters(
$className,
'__construct'
);
$canProvideAll = true;
foreach ($parameterCollection as $parameter) {
if ($parameter->isBuiltin()) {
continue;
}
if (! $parameter->isOptional()) {
$typeName = $parameter->getTypeName();
if (! $typeName || ! $this->container->has($typeName)) {
$canProvideAll = false;
break;
}
}
}
if ($canProvideAll) {
$this->logger->debug("Fallback strategy 2 (dependency provision) succeeded for {$className->getShortName()}");
return true;
}
// Strategy 3: For absolutely critical middlewares, allow them anyway
if ($this->isCriticalMiddleware($className)) {
$this->logger->warning("Fallback strategy 3 (critical override) applied for {$className->getShortName()}");
return true;
}
return false;
} catch (\Throwable $e) {
$this->logger->error("Fallback strategies failed for {$className->getShortName()}: " . $e->getMessage());
return false;
}
}
/**
* Check if a middleware can be instantiated by trying to resolve it through the container
*/
private function canInstantiate(ClassName $middlewareClass): bool
{
try {
// Check if class exists
if (! $middlewareClass->exists()) {
$this->logger->warning("Class does not exist: {$middlewareClass->getShortName()}");
return false;
}
// Check if class is instantiable
if (! $this->reflectionProvider->isInstantiable($middlewareClass)) {
$this->logger->warning("Class is not instantiable: {$middlewareClass->getShortName()}");
return false;
}
// Try to actually resolve the middleware through the container
// This is the most reliable way to check if all dependencies are available
try {
/** @var class-string<object> $className */
$className = $middlewareClass->getFullyQualified();
$instance = $this->container->get($className);
$this->logger->debug("Successfully resolved {$middlewareClass->getShortName()} through container");
return true;
} catch (\Throwable $containerException) {
$this->logger->warning("Cannot resolve {$middlewareClass->getShortName()} through container: " . $containerException->getMessage());
// Fallback to the old dependency checking method for more detailed info
return $this->checkDependenciesManually($middlewareClass);
}
} catch (\Throwable $e) {
$this->logger->error("Error checking instantiation for {$middlewareClass->getShortName()}: " . $e->getMessage());
return false;
}
}
/**
* Fallback method to manually check dependencies when container resolution fails
*/
private function checkDependenciesManually(ClassName $middlewareClass): bool
{
try {
$parameterCollection = $this->reflectionProvider->getMethodParameters(
$middlewareClass,
'__construct'
);
$missingDependencies = [];
foreach ($parameterCollection as $parameter) {
// Skip built-in types
if ($parameter->isBuiltin()) {
continue;
}
$typeName = $parameter->getTypeName();
if (! $typeName) {
continue;
}
// Check if required dependency is available in container
if (! $parameter->isOptional()) {
if (! $this->container->has($typeName)) {
$missingDependencies[] = ClassName::create($typeName);
}
}
}
if (! empty($missingDependencies)) {
$depNames = array_map(fn (ClassName $dep) => $dep->getShortName(), $missingDependencies);
$this->logger->debug("Manual dependency check - Missing dependencies for {$middlewareClass->getShortName()}: " . implode(', ', $depNames));
return false;
}
return true;
} catch (\Throwable $e) {
$this->logger->error("Error in manual dependency check for {$middlewareClass->getShortName()}: " . $e->getMessage());
return false;
}
}
/**
* Validate that all critical middlewares are present
*
* @throws MiddlewareDependencyException
*/
/**
* @param array<string> $availableMiddlewares
*/
private function validateCriticalMiddlewares(array $availableMiddlewares): void
{
$missingCritical = [];
foreach (self::CRITICAL_MIDDLEWARES as $criticalMiddleware) {
if (! in_array($criticalMiddleware, $availableMiddlewares, true)) {
$missingCritical[] = ClassName::create($criticalMiddleware);
}
}
if (! empty($missingCritical)) {
$missingNames = array_map(fn (ClassName $c) => $c->getShortName(), $missingCritical);
$this->logger->critical("CRITICAL - Missing essential middlewares: " . implode(', ', $missingNames));
throw MiddlewareDependencyException::missingDependencies(
ClassName::create('CriticalMiddlewares'),
$missingCritical
);
}
}
/**
* Get dependency information for debugging
*
* @param array<string> $middlewareClasses
* @return array<string, mixed>
*/
public function getDependencyInfo(array $middlewareClasses): array
{
$info = [];
$classNames = $this->normalizeClassNames($middlewareClasses);
foreach ($classNames as $middlewareClass) {
try {
$info[$middlewareClass->getFullyQualified()] = [
'short_name' => $middlewareClass->getShortName(),
'namespace' => $middlewareClass->getNamespace(),
'exists' => $middlewareClass->exists(),
'is_instantiable' => $this->reflectionProvider->isInstantiable($middlewareClass),
'is_critical' => $this->isCriticalMiddleware($middlewareClass),
'middleware_dependencies' => $this->getMiddlewareDependencies($middlewareClass),
'service_dependencies' => $this->getServiceDependencies($middlewareClass),
'provides' => $this->getProvidedServices($middlewareClass),
'optional' => $this->getOptionalDependencies($middlewareClass),
'can_instantiate' => $this->canInstantiate($middlewareClass),
];
} catch (\Throwable $e) {
$info[$middlewareClass->getFullyQualified()] = [
'error' => $e->getMessage(),
'can_instantiate' => false,
];
}
}
return $info;
}
}

View File

@@ -1,19 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
use App\Framework\Cache\Cache;
use App\Framework\CircuitBreaker\CircuitBreaker;
use App\Framework\DI\Container;
use App\Framework\Http\Exceptions\MiddlewareTimeoutException;
use App\Framework\Http\Metrics\MiddlewareMetricsCollector;
use App\Framework\Logging\DefaultLogger;
final readonly class MiddlewareInvoker
{
private DefaultLogger $logger;
/**
* Default timeout for middleware execution in seconds
* Set to 0 to disable timeout
*/
private float $defaultTimeout;
/**
* Map of middleware class names to their specific timeouts
* @var array<string, float>
*/
private array $middlewareTimeouts;
/**
* Circuit breaker for preventing repeatedly failing middlewares from being executed
*/
private MiddlewareCircuitBreaker $circuitBreaker;
/**
* Metrics collector for middleware execution
*/
private MiddlewareMetricsCollector $metricsCollector;
public function __construct(
private Container $container
private Container $container,
?float $defaultTimeout = null,
array $middlewareTimeouts = [],
?MiddlewareCircuitBreaker $circuitBreaker = null,
?MiddlewareMetricsCollector $metricsCollector = null
) {
$this->logger = $this->container->get(DefaultLogger::class);
$this->defaultTimeout = $defaultTimeout ?? (float)($_ENV['MIDDLEWARE_TIMEOUT'] ?? 5.0);
$this->middlewareTimeouts = $middlewareTimeouts;
$this->circuitBreaker = $circuitBreaker ?? new MiddlewareCircuitBreaker(
$this->container->get(CircuitBreaker::class)
);
$this->metricsCollector = $metricsCollector ?? new MiddlewareMetricsCollector(
$this->container->get(Cache::class)
);
}
/**
@@ -21,45 +60,136 @@ final readonly class MiddlewareInvoker
*/
public function invoke(
HttpMiddleware|string $middleware,
MiddlewareContext $context,
callable $next,
RequestStateManager $stateManager
MiddlewareContext $context,
Next $next,
RequestStateManager $stateManager
): MiddlewareContext {
// Middleware-Instanz holen, falls ein Klassenname übergeben wurde
if (is_string($middleware)) {
error_log("MiddlewareInvoker: Getting instance for {$middleware}");
$middleware = $this->container->get($middleware);
error_log("MiddlewareInvoker: Successfully got instance for " . get_class($middleware));
}
$middlewareName = is_object($middleware) ? get_class($middleware) : (string)$middleware;
$responseBefore = $context->response;
// Get the appropriate timeout for this middleware
$timeout = $this->getTimeoutForMiddleware($middlewareName);
/*// Check if middleware is allowed to execute by the circuit breaker
if (!$this->circuitBreaker->isAllowed($middlewareName)) {
debug("Middleware {$middlewareName} is not allowed to execute");
$this->logger->warning("Circuit breaker prevented execution of middleware {$middlewareName}");
// Skip this middleware and continue with the next one
return $next($context);
}*/
// Debug-Ausgabe vor der Ausführung
$this->logger->debug('VOR: Middleware ' . get_class($middleware) .
$this->logger->debug('VOR: Middleware ' . $middlewareName .
' - hasResponse: ' . ($context->hasResponse() ? 'ja' : 'nein'));
try {
// Start timing the middleware execution
$startTime = microtime(true);
// Middleware ausführen
/* @var $middleware HttpMiddleware */
$resultContext = $middleware($context, $next, $stateManager);
// Calculate execution time
$executionTime = microtime(true) - $startTime;
// Check if middleware exceeded its timeout
if ($timeout > 0 && $executionTime > $timeout) {
throw new MiddlewareTimeoutException(
$middlewareName,
$timeout,
0,
new \RuntimeException("Execution time: {$executionTime} seconds")
);
}
// Log execution time for monitoring
$this->logger->debug("Middleware {$middlewareName} executed in {$executionTime} seconds");
$response = $resultContext->response;
if ($response !== null && $responseBefore !== null && ! is_a($responseBefore, get_class($response))) {
#debug(get_class($middleware));
}
// Typüberprüfung
if (!$resultContext instanceof MiddlewareContext) {
if (! $resultContext instanceof MiddlewareContext) {
throw new \RuntimeException(sprintf(
'Middleware %s hat keinen MiddlewareContext zurückgegeben, sondern %s',
get_class($middleware),
$middlewareName,
is_object($resultContext) ? get_class($resultContext) : gettype($resultContext)
));
}
// Record successful execution in circuit breaker
$this->circuitBreaker->recordSuccess($middlewareName);
// Record metrics for successful execution
$this->metricsCollector->recordExecution(
$middlewareName,
$executionTime,
true
);
// Debug-Ausgabe nach der Ausführung
$this->logger->debug('NACH: Middleware ' . get_class($middleware) .
$this->logger->debug('NACH: Middleware ' . $middlewareName .
' - hasResponse: ' . ($resultContext->hasResponse() ? 'ja' : 'nein'));
return $resultContext;
} catch (\Throwable $e) {
$this->logger->error('Fehler in Middleware ' . get_class($middleware) . ': ' . $e->getMessage());
// Record failure in circuit breaker
$this->circuitBreaker->recordFailure($middlewareName);
// Record metrics for failed execution
$errorType = get_class($e);
$this->metricsCollector->recordExecution(
$middlewareName,
microtime(true) - $startTime, // Calculate execution time even for failed executions
false,
$errorType
);
$this->logger->error('Fehler in Middleware ' . $middlewareName . ': ' . $e->getMessage(), [
'exception' => $e,
'middleware' => $middlewareName,
'trace' => $e->getTraceAsString(),
]);
// Re-throw the exception to be caught by ExceptionHandlingMiddleware
throw $e;
// Bei Fehler den ursprünglichen Kontext zurückgeben
return $context;
}
}
/**
* Gets the appropriate timeout for a middleware
*
* @param string $middlewareName The fully qualified class name of the middleware
* @return float The timeout in seconds, or 0 to disable timeout
*/
private function getTimeoutForMiddleware(string $middlewareName): float
{
// Check if there's a specific timeout for this middleware
if (isset($this->middlewareTimeouts[$middlewareName])) {
return $this->middlewareTimeouts[$middlewareName];
}
// Check if there's a specific timeout for this middleware's base class
foreach ($this->middlewareTimeouts as $class => $timeout) {
if (is_subclass_of($middlewareName, $class)) {
return $timeout;
}
}
// Return the default timeout
return $this->defaultTimeout;
}
}

View File

@@ -4,84 +4,286 @@ declare(strict_types=1);
namespace App\Framework\Http;
use App\Framework\Core\InterfaceImplementationLocator;
use App\Framework\Cache\Cache;
use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\DI\Container;
use App\Framework\Discovery\Results\DiscoveryResults;
use App\Framework\Http\HttpMiddleware;
use App\Framework\Http\Middlewares\ControllerRequestMiddleware;
use App\Framework\Http\Middlewares\RoutingMiddleware;
use App\Framework\Validation\ValidationErrorMiddleware;
use App\Framework\Discovery\Results\DiscoveryRegistry;
use App\Framework\Http\Middlewares\DDoSProtectionMiddleware;
use App\Framework\Http\Middlewares\RateLimitMiddleware;
use App\Framework\Http\Middlewares\WafMiddleware;
use App\Framework\Logging\Logger;
use App\Framework\Reflection\ReflectionProvider;
/**
* Verwaltet die HTTP-Middleware-Pipeline
*/
final readonly class MiddlewareManager
final readonly class MiddlewareManager implements MiddlewareManagerInterface
{
/**
* @var array<string> Array mit Middleware-Klassen
*/
#private array $middlewares = [];
public HttpMiddlewareChain $chain;
public function __construct(
private Container $container,
private DiscoveryResults $discoveryResults,
)
{
// Standard-Middlewares registrieren
/*$this->middlewares = [
ControllerRequestMiddleware::class,
ValidationErrorMiddleware::class,
RoutingMiddleware::class
];*/
private DiscoveryRegistry $discoveryRegistry,
private ReflectionProvider $reflectionProvider,
private Cache $cache,
) {
$middlewares = $this->buildMiddlewareStack();
foreach($middlewares as $middleware) {
#echo $middleware . " " . $this->getMiddlewarePriority($middleware) . "<br>". PHP_EOL;
}
error_log("MiddlewareManager: Middleware stack: " . implode(', ', array_map(fn ($m) => basename($m), $middlewares)));
$this->chain = new HttpMiddlewareChain(
$middlewares,
fn() => new HttpResponse(Status::NOT_FOUND),
$this->container
);
}
private function buildMiddlewareStack(): array
{
$middlewares = $this->discoveryResults->get(HttpMiddleware::class);
$middlewares = array_column($middlewares, 'class');
return $this->sortMiddlewaresByPriority($middlewares);
// Explizite Reihenfolge definieren - wichtigste zuerst
$explicitOrder = [
// 0. Exception Handling - MUSS absolut zuerst kommen, um alle Exceptions zu fangen
\App\Framework\Http\Middlewares\ExceptionHandlingMiddleware::class,
// 1. System und Error Handling
\App\Framework\Http\Middlewares\RequestIdMiddleware::class,
// 2. Security und DDoS Protection
DDoSProtectionMiddleware::class,
WafMiddleware::class,
// 3. Session - MUSS vor Auth und CSRF kommen!
\App\Framework\Http\Session\SessionMiddleware::class,
// 4. Security und Rate Limiting
RateLimitMiddleware::class,
#\App\Application\Security\Middleware\SecurityEventMiddleware::class,
// 5. Headers und CORS
\App\Framework\Http\Middlewares\SecurityHeaderMiddleware::class,
\App\Framework\Http\Middlewares\RemovePoweredByMiddleware::class,
\App\Framework\Http\Middlewares\CORSMiddleware::class,
// 6. Authentication und CSRF (brauchen Session)
\App\Framework\Http\Middlewares\AuthMiddleware::class,
\App\Framework\Http\Middlewares\CsrfMiddleware::class,
\App\Framework\Http\Middlewares\HoneypotMiddleware::class,
// 7. Routing und Request Processing
\App\Framework\Http\Middlewares\RoutingMiddleware::class,
\App\Framework\Http\Middlewares\ControllerRequestMiddleware::class,
// 8. Content und Static Files
#\App\Framework\Http\Middlewares\ServeStaticFilesMiddleware::class,
\App\Framework\Http\Middlewares\ResponseGeneratorMiddleware::class,
\App\Framework\Http\Middlewares\FormDataResponseMiddleware::class, // Temporarily disabled
// 9. Monitoring und Analytics
\App\Framework\Analytics\Middleware\AnalyticsMiddleware::class,
\App\Framework\Performance\Middleware\RequestPerformanceMiddleware::class,
\App\Framework\Performance\Middleware\RoutingPerformanceMiddleware::class,
\App\Framework\Performance\Middleware\ControllerPerformanceMiddleware::class,
\App\Framework\Tracing\TracingMiddleware::class,
// 10. Logging (am Ende)
\App\Framework\Http\Middlewares\RequestLoggingMiddleware::class,
\App\Framework\Http\Middlewares\LoggingMiddleware::class,
\App\Framework\Performance\Middleware\PerformanceDebugMiddleware::class,
\App\Framework\Security\RequestSigning\RequestSigningMiddleware::class,
// 11. FALLBACK - Absolut letztes Middleware (nur wenn keine Response vorhanden)
\App\Framework\Http\Middlewares\DefaultResponseMiddleware::class,
];
// Use the refactored robust dependency resolution
try {
$resolved = $this->resolveDependencies($explicitOrder);
#error_log("MiddlewareManager: Using dependency resolution - Count: " . count($resolved));
// WICHTIG: Array umkehren, da die Liste in gewünschter Ausführungsreihenfolge definiert ist
// aber die Middleware-Chain sie in der gegebenen Reihenfolge abarbeitet
return array_reverse($resolved);
} catch (\Throwable $e) {
#error_log("MiddlewareManager: Dependency resolution failed: " . $e->getMessage());
#error_log("MiddlewareManager: Falling back to explicit order - Count: " . count($explicitOrder));
#foreach ($explicitOrder as $i => $middleware) {
# error_log("MiddlewareManager: Explicit #{$i}: " . basename($middleware));
#}
// WICHTIG: Array umkehren, da die Liste in gewünschter Ausführungsreihenfolge definiert ist
return array_reverse($explicitOrder);
}
}
/**
* Erstellt eine Middleware-Chain mit den registrierten Middlewares
* Resolve middleware dependencies using constructor analysis
* @param array<string> $middlewares
* @return array<string>
*/
/*public function createMiddlewareChain(callable $fallbackHandler): HttpMiddlewareChain
private function resolveDependencies(array $middlewares): array
{
return new HttpMiddlewareChain(
$this->middlewares,
$fallbackHandler,
$this->container
$logger = $this->container->get(Logger::class);
$resolver = new MiddlewareDependencyResolver(
$this->reflectionProvider,
$this->container,
$logger
);
}*/
$resolved = $resolver->resolve($middlewares);
// Log dependency resolution info
#error_log("MiddlewareManager: Dependency resolution completed");
#error_log("MiddlewareManager: Resolved " . $resolved->count() . " middlewares");
#error_log("MiddlewareManager: Execution order: " . implode(' → ', array_map(fn ($m) => basename($m), $resolved->getMiddlewares())));
// Log dependency chains for debugging
$info = $resolved->getResolutionInfo();
#if (! empty($info['dependency_chains'])) {
# error_log("MiddlewareManager: Dependency chains: " . json_encode($info['dependency_chains']));
#}
#if (! empty($info['circular_dependencies'])) {
# error_log("MiddlewareManager: WARNING - Circular dependencies detected: " . json_encode($info['circular_dependencies']));
#}
return $resolved->getMiddlewares();
}
/**
* Get middleware dependency information for debugging
*/
/**
* @return array<string, mixed>
*/
public function getDependencyInfo(): array
{
$explicitOrder = $this->getExplicitOrder();
$classNameObjects = array_map(fn ($class) => ClassName::create($class), $explicitOrder);
$logger = $this->container->get(Logger::class);
$resolver = new MiddlewareDependencyResolver(
$this->reflectionProvider,
$this->container,
$logger
);
return $resolver->getDependencyInfo($classNameObjects);
}
/**
* Get the explicit order array (for testing and debugging)
* @return array<string>
*/
private function getExplicitOrder(): array
{
// Return the same explicit order array
return [
// 1. System und Error Handling
\App\Framework\Http\Middlewares\RequestIdMiddleware::class,
\App\Framework\Http\Middlewares\ExceptionHandlingMiddleware::class,
// 4. Session - MUSS vor Auth und CSRF kommen!
\App\Framework\Http\Session\SessionMiddleware::class,
// 2. Security und Rate Limiting
RateLimitMiddleware::class,
#\App\Application\Security\Middleware\SecurityEventMiddleware::class,
// 3. Headers und CORS
\App\Framework\Http\Middlewares\SecurityHeaderMiddleware::class,
\App\Framework\Http\Middlewares\RemovePoweredByMiddleware::class,
\App\Framework\Http\Middlewares\CORSMiddleware::class,
// 5. Authentication und CSRF (brauchen Session)
\App\Framework\Http\Middlewares\AuthMiddleware::class,
\App\Framework\Http\Middlewares\CsrfMiddleware::class,
\App\Framework\Http\Middlewares\HoneypotMiddleware::class,
// 6. Routing und Request Processing
\App\Framework\Http\Middlewares\RoutingMiddleware::class,
\App\Framework\Http\Middlewares\ControllerRequestMiddleware::class,
// 7. Content und Static Files
#\App\Framework\Http\Middlewares\ServeStaticFilesMiddleware::class,
\App\Framework\Http\Middlewares\ResponseGeneratorMiddleware::class,
\App\Framework\Http\Middlewares\FormDataResponseMiddleware::class, // Temporarily disabled
// 8. Monitoring und Analytics
\App\Framework\Analytics\Middleware\AnalyticsMiddleware::class,
\App\Framework\Performance\Middleware\RequestPerformanceMiddleware::class,
\App\Framework\Performance\Middleware\RoutingPerformanceMiddleware::class,
\App\Framework\Performance\Middleware\ControllerPerformanceMiddleware::class,
\App\Framework\Tracing\TracingMiddleware::class,
// 9. Logging (am Ende)
\App\Framework\Http\Middlewares\RequestLoggingMiddleware::class,
\App\Framework\Http\Middlewares\LoggingMiddleware::class,
\App\Framework\Performance\Middleware\PerformanceDebugMiddleware::class,
\App\Framework\Security\RequestSigning\RequestSigningMiddleware::class,
// 10. FALLBACK - Absolut letztes Middleware (nur wenn keine Response vorhanden)
\App\Framework\Http\Middlewares\DefaultResponseMiddleware::class,
];
}
public function getPriorityForClass(object|string $middlewareClass): int
{
$className = is_string($middlewareClass) ? $middlewareClass : $middlewareClass::class;
$className = ltrim($className, '\\');
$cacheKey = "middleware_priority:{$className}";
$cacheItem = $this->cache->remember($cacheKey, function () use ($className) {
return $this->resolvePriorityFromReflection($className);
}, 3600);
return is_int($cacheItem->value) ? $cacheItem->value : MiddlewarePriority::BUSINESS_LOGIC->value;
}
private function resolvePriorityFromReflection(string $className): int
{
try {
$classNameObj = ClassName::create($className);
$attrs = $this->reflectionProvider->getAttributes($classNameObj, MiddlewarePriorityAttribute::class);
if ($attrs->count() > 0) {
/** @var MiddlewarePriorityAttribute|null $attr */
$attr = $attrs->getFirstByType(MiddlewarePriorityAttribute::class)?->newInstance();
if ($attr instanceof MiddlewarePriorityAttribute) {
$basePriority = $attr->priority->value;
$offset = $attr->offset ?? 0;
return $basePriority + $offset;
}
}
} catch (\ReflectionException $e) {
// Class doesn't exist or can't be reflected, use default
}
// Default priority
return MiddlewarePriority::BUSINESS_LOGIC->value;
}
// Legacy static method for backward compatibility
public static function getPriority(object|string $middlewareClass): int
{
$reflection = new \ReflectionClass($middlewareClass);
$attrs = $reflection->getAttributes(MiddlewarePriorityAttribute::class);
if ($attrs) {
/** @var MiddlewarePriorityAttribute $attr */
$attr = $attrs[0]->newInstance();
if ($attr->offset !== 0) {
return $attr->priority->value + $attr->offset;
// This is a fallback for any code that still uses the static method
$className = is_string($middlewareClass) ? $middlewareClass : $middlewareClass::class;
$className = ltrim($className, '\\');
try {
$reflection = new \ReflectionClass($className);
$attrs = $reflection->getAttributes(MiddlewarePriorityAttribute::class);
if ($attrs) {
/** @var MiddlewarePriorityAttribute $attr */
$attr = $attrs[0]->newInstance();
$basePriority = $attr->priority->value;
$offset = $attr->offset ?? 0;
return $basePriority + $offset;
}
return $attr->priority->value;
} catch (\ReflectionException $e) {
// Class doesn't exist or can't be reflected, use default
}
// Standard-Priorität (oder Fehlermeldung)
return MiddlewarePriority::BUSINESS_LOGIC->value;
}
@@ -101,24 +303,52 @@ final readonly class MiddlewareManager
// Hilfsmethode zum Abrufen der Middleware-Priorität
private function getMiddlewarePriority(object|string $middlewareClass): int
{
$priority = $this->discoveryResults->get(MiddlewarePriorityAttribute::class);
$middlewareClass = ltrim($middlewareClass, '\\');
$className = is_string($middlewareClass) ? $middlewareClass : $middlewareClass::class;
$className = ltrim($className, '\\');
foreach($priority as $item) {
#dd($middlewareClass);
if ($item['class'] === $middlewareClass) {
#debug($item['attribute_data']['priority']);
#dd($item['attribute_data']['priority']->value);
$priority = $item['attribute_data']['priority'];
if(!is_int($priority)) {
$priority = $priority->value;
$cacheKey = "middleware_priority_discovery:{$className}";
$cacheItem = $this->cache->remember($cacheKey, function () use ($className) {
// Use discovery registry to find priority
$priorities = $this->discoveryRegistry->attributes()->get(MiddlewarePriorityAttribute::class);
foreach ($priorities as $discoveredAttribute) {
if ($discoveredAttribute->className->getFullyQualified() === $className) {
$additionalData = $discoveredAttribute->additionalData;
$priority = $additionalData['priority'] ?? null;
if (! is_int($priority)) {
$priority = $priority->value;
}
return $priority + ($additionalData['offset'] ?? 0);
}
return $priority + ($item['attribute_data']['offset'] ?? 0);
}
}
// Middleware has no Priority: Set to default
return MiddlewarePriority::BUSINESS_LOGIC->value;
// Default priority
return MiddlewarePriority::BUSINESS_LOGIC->value;
}, 3600);
return is_int($cacheItem->value) ? $cacheItem->value : MiddlewarePriority::BUSINESS_LOGIC->value;
}
/**
* Clear the priority cache (useful for testing)
*/
public function clearPriorityCache(): void
{
// Clear cache entries that start with our middleware priority prefix
$this->cache->forget('middleware_priority:*');
$this->cache->forget('middleware_priority_discovery:*');
}
/**
* Get cache statistics for debugging
*/
public function getCacheStats(): array
{
return [
'cache_implementation' => get_class($this->cache),
'reflection_provider' => get_class($this->reflectionProvider),
];
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
/**
* Interface für Middleware Manager
*/
interface MiddlewareManagerInterface
{
public HttpMiddlewareChain $chain {get;}
}

View File

@@ -1,18 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
use Attribute;
#[\Attribute(Attribute::TARGET_CLASS)]
enum MiddlewarePriority: int
{
case FIRST = 1200;
// Systemebene - wird vor allem anderen ausgeführt
case VERY_EARLY = 1100; // Vor Error Handling, für Request-ID
case ERROR_HANDLING = 1000;
case SECURITY = 900;
case SESSION = 800;
case RATE_LIMITING = 950; // Rate Limiting vor anderen Security-Checks
case SESSION = 930; // Session muss vor Security-Middleware laufen
case BOT_DETECTION = 925; // Bot-Erkennung vor Input-Validierung
case INPUT_VALIDATION = 910; // Input-Validierung vor allgemeiner Security
case SECURITY = 900; // Allgemeine Security (Headers, CSRF)
case CORS = 700;
// Anwendungsebene
@@ -23,9 +25,14 @@ enum MiddlewarePriority: int
// Geschäftslogik
case BUSINESS_LOGIC = 100;
case CONTROLLER = 50; // Controller-spezifische Middleware
// Logging und Monitoring - wird nach allem anderen ausgeführt
case LOGGING = 0;
case LAST = -100; // Performance Debug und finale Monitoring
// Fallback - absolut letzte Middleware
case FALLBACK = -1000;
/**
* Hilfsmethode zum Erzeugen einer benutzerdefinierten Priorität
@@ -44,7 +51,10 @@ enum MiddlewarePriority: int
return match($this) {
self::VERY_EARLY => 'Sehr frühe Ausführung (Request-ID)',
self::ERROR_HANDLING => 'Fehlerbehandlung',
self::SECURITY => 'Sicherheit (Firewall, CSRF)',
self::RATE_LIMITING => 'Rate Limiting (Request-Begrenzung)',
self::BOT_DETECTION => 'Bot-Erkennung (User-Agent Analyse)',
self::INPUT_VALIDATION => 'Input-Validierung (XSS/SQL-Injection)',
self::SECURITY => 'Sicherheit (Headers, CSRF)',
self::SESSION => 'Session-Verwaltung',
self::CORS => 'CORS-Header',
self::ROUTING => 'Routing und URL-Rewriting',
@@ -52,7 +62,10 @@ enum MiddlewarePriority: int
self::AUTHENTICATION => 'Authentifizierung',
self::AUTHORIZATION => 'Autorisierung',
self::BUSINESS_LOGIC => 'Geschäftslogik',
self::CONTROLLER => 'Controller-spezifische Middleware',
self::LOGGING => 'Logging (späteste Ausführung)',
self::LAST => 'Performance Debug und finale Monitoring',
self::FALLBACK => 'Fallback Response Handler',
};
}
}

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
@@ -11,5 +12,6 @@ class MiddlewarePriorityAttribute
public function __construct(
public MiddlewarePriority $priority,
public int $offset = 0
) {}
) {
}
}

View File

@@ -1,16 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
use WeakMap;
class MiddlewareStateManager
final class MiddlewareStateManager
{
private Weakmap $requestStates;
public function __construct()
{
$this->requestStates = new WeakMap();
/**
* Creates a new middleware state manager.
*
* The WeakMap automatically garbage collects request states when
* the associated Request objects are no longer referenced, preventing
* memory leaks in long-running applications.
*
* @param WeakMap<object, mixed> $requestStates Storage for request-specific state data
*/
public function __construct(
private WeakMap $requestStates = new WeakMap()
) {
}
public function forRequest(Request $request): RequestStateManager

View File

@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Middlewares;
use App\Framework\Attributes\ApiVersionAttribute;
use App\Framework\Http\Headers;
use App\Framework\Http\HttpMiddleware;
use App\Framework\Http\HttpResponse;
use App\Framework\Http\MiddlewareContext;
use App\Framework\Http\MiddlewarePriority;
use App\Framework\Http\MiddlewarePriorityAttribute;
use App\Framework\Http\Next;
use App\Framework\Http\RequestStateManager;
use App\Framework\Http\StateKey;
use App\Framework\Http\Status;
use App\Framework\Http\Versioning\ApiVersion;
use App\Framework\Http\Versioning\VersionExtractor;
use App\Framework\Http\Versioning\VersioningConfig;
use App\Framework\Serialization\JsonSerializer;
/**
* Middleware for API versioning support
*/
#[MiddlewarePriorityAttribute(MiddlewarePriority::HIGH)]
final readonly class ApiVersioningMiddleware implements HttpMiddleware
{
public function __construct(
private VersioningConfig $config,
private VersionExtractor $versionExtractor,
private JsonSerializer $jsonSerializer
) {
}
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
{
// Extract requested version from request
$requestedVersion = $this->versionExtractor->extractVersion($context->request);
if ($requestedVersion === null) {
$requestedVersion = $this->config->defaultVersion;
}
// Validate version support
if ($this->config->strictVersioning && ! $this->config->isVersionSupported($requestedVersion)) {
$response = $this->createVersionNotSupportedResponse($requestedVersion);
return $context->withResponse($response);
}
// Store version in request state for downstream use
$stateManager->set(StateKey::fromString('api_version'), $requestedVersion);
// Process request through next middleware
$context = $next($context);
// Add version headers to response
$response = $this->addVersionHeaders($context->response, $requestedVersion);
return $context->withResponse($response);
}
public function validateRouteVersion(ApiVersionAttribute $versionAttribute, ApiVersion $requestedVersion): bool
{
// Check if endpoint is removed
if ($versionAttribute->isRemoved()) {
$removedVersion = $versionAttribute->getRemovedVersion();
if ($removedVersion && $requestedVersion->isNewerThan($removedVersion)) {
return false;
}
}
// Check version compatibility
return $requestedVersion->isCompatibleWith($versionAttribute->version);
}
public function getDeprecationWarning(ApiVersionAttribute $versionAttribute, ApiVersion $requestedVersion): ?string
{
if (! $versionAttribute->isDeprecated()) {
return null;
}
$deprecatedVersion = $versionAttribute->getDeprecatedVersion();
if ($deprecatedVersion && $requestedVersion->isNewerThan($deprecatedVersion)) {
$removedIn = $versionAttribute->removedIn ?? 'a future version';
return "This endpoint is deprecated and will be removed in {$removedIn}";
}
return null;
}
private function addVersionHeaders(HttpResponse $response, ApiVersion $version): HttpResponse
{
$headers = $response->headers->toArray();
$headers['API-Version'] = $version->toString();
$headers['X-API-Version-Latest'] = $this->config->getLatestVersion()->toString();
return new HttpResponse(
status: $response->status,
headers: new Headers($headers),
body: $response->body
);
}
private function createVersionNotSupportedResponse(ApiVersion $requestedVersion): HttpResponse
{
$supportedVersions = array_map(
fn ($v) => $v->toString(),
$this->config->supportedVersions
);
$error = [
'error' => 'Unsupported API version',
'requested_version' => $requestedVersion->toString(),
'supported_versions' => $supportedVersions,
'latest_version' => $this->config->getLatestVersion()->toString(),
];
return new HttpResponse(
status: Status::BAD_REQUEST,
headers: new Headers([
'Content-Type' => 'application/json',
'API-Version' => $this->config->getLatestVersion()->toString(),
]),
body: $this->jsonSerializer->serialize($error)
);
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Middlewares;
use App\Framework\Http\HttpMiddleware;
@@ -7,14 +9,15 @@ use App\Framework\Http\HttpResponse;
use App\Framework\Http\MiddlewareContext;
use App\Framework\Http\MiddlewarePriority;
use App\Framework\Http\MiddlewarePriorityAttribute;
use App\Framework\Http\RequestStateManager;
use App\Framework\Http\Next;
use App\Framework\Http\Request;
use App\Framework\Http\RequestStateManager;
use App\Framework\Http\Status;
#[MiddlewarePriorityAttribute(MiddlewarePriority::AUTHENTICATION)]
final readonly class AuthMiddleware implements HttpMiddleware
{
public function __invoke(MiddlewareContext $context, callable $next, RequestStateManager $stateManager): MiddlewareContext
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
{
/*if (str_starts_with($context->request->path, '/admin')) {
return new HttpResponse(Status::FORBIDDEN, body: 'Zugriff verweigert');

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Middlewares;
@@ -7,8 +8,8 @@ use App\Framework\Http\HttpMiddleware;
use App\Framework\Http\MiddlewareContext;
use App\Framework\Http\MiddlewarePriority;
use App\Framework\Http\MiddlewarePriorityAttribute;
use App\Framework\Http\Next;
use App\Framework\Http\RequestStateManager;
use App\Framework\Http\Request;
use App\Framework\Http\Response;
use App\Framework\Http\ResponseManipulator;
@@ -17,9 +18,10 @@ final readonly class CORSMiddleware implements HttpMiddleware
{
public function __construct(
private ResponseManipulator $manipulator
) {}
) {
}
public function __invoke(MiddlewareContext $context, callable $next, RequestStateManager $stateManager): MiddlewareContext
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
{
// Debug-Ausgabe vor der Verarbeitung
#var_dump('CORS Middleware Start - Kontext hat Response: ' . ($context->hasResponse() ? 'ja' : 'nein'));

View File

@@ -1,26 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Middlewares;
use App\Framework\DI\DefaultContainer;
use App\Framework\Http\HttpMiddleware;
use App\Framework\Http\MiddlewareContext;
use App\Framework\Http\Next;
use App\Framework\Http\RequestStateManager;
use App\Framework\Http\Request;
use App\Framework\Http\Response;
final readonly class ControllerRequestMiddleware implements HttpMiddleware
{
public function __construct(
#private Container $container
) {}
) {
}
/**
* @param RequestStateManager $stateManager
* @inheritDoc
*/
public function __invoke(MiddlewareContext $context, callable $next, RequestStateManager $stateManager): MiddlewareContext
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
{

View File

@@ -1,47 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Middlewares;
use App\Framework\DI\Container;
use App\Framework\Http\HttpMiddleware;
use App\Framework\Http\Method;
use App\Framework\Http\MiddlewareContext;
use App\Framework\Http\MiddlewarePriority;
use App\Framework\Http\MiddlewarePriorityAttribute;
use App\Framework\Http\Next;
use App\Framework\Http\Request;
use App\Framework\Http\RequestStateManager;
use App\Framework\Http\Session\Session;
use App\Framework\Http\Session\SessionInterface;
use App\Framework\Security\CsrfToken;
#[MiddlewarePriorityAttribute(MiddlewarePriority::SECURITY, -150)] // Push after Session Creation
#[MiddlewarePriorityAttribute(MiddlewarePriority::SECURITY)] // Explizite Reihenfolge in MiddlewareManager
final readonly class CsrfMiddleware implements HttpMiddleware
{
public function __construct(
private Session $session,
){}
private Container $container,
) {
}
public function __invoke(MiddlewareContext $context, callable $next, RequestStateManager $stateManager): MiddlewareContext
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
{
$request = $context->request;
if (!$this->session->isStarted()) {
throw new \RuntimeException('Session must be started before CSRF validation');
// Try to get session from container - graceful fallback if not available
try {
$session = $this->container->get(SessionInterface::class);
} catch (\Throwable $e) {
// Session not available - skip CSRF validation gracefully
error_log("CsrfMiddleware: Session not available, skipping CSRF validation");
return $next($context);
}
if($request->method === Method::POST) {
// FormId ist jetzt immer vorhanden durch automatische Generierung
$formId = $request->parsedBody->get('_form_id');
$token = $request->parsedBody->get('_token');
if (!$formId || !$token) {
throw new \Exception('CSRF-Daten fehlen');
}
$valid = $this->session->csrf->validateToken($formId, $token);
if(!$valid) {
throw new \Exception('CSRF-Token ungültig');
}
if (in_array($request->method, [Method::POST, Method::PUT, Method::DELETE, Method::PATCH])) {
$this->validateCsrfToken($request, $session);
}
return $next($context);
$context = $next($context);
// After successful request processing, optionally rotate token for enhanced security
// This can be enabled for high-security applications
// $this->rotateTokenIfNeeded($request, $session);
return $context;
}
/**
* Validates CSRF token from the request
*/
private function validateCsrfToken(Request $request, SessionInterface $session): void
{
$formId = $request->parsedBody->get('_form_id') ??
$request->headers->getFirst('X-CSRF-Form-ID');
$tokenValue = $request->parsedBody->get('_token') ??
$request->headers->getFirst('X-CSRF-Token');
// Debug: Log CSRF validation attempt
error_log("CSRF Debug: Validating tokens for form_id='$formId'");
if (! $formId || ! $tokenValue) {
throw new \InvalidArgumentException('CSRF protection requires both form ID and token');
}
try {
$token = CsrfToken::fromString($tokenValue);
} catch (\InvalidArgumentException $e) {
throw new \InvalidArgumentException('Invalid CSRF token format: ' . $e->getMessage());
}
if (! $session->csrf->validateToken($formId, $token)) {
error_log("CSRF validation failed for form: " . $formId);
throw new \RuntimeException('CSRF token validation failed. This may indicate a security threat.');
}
}
/**
* Rotates CSRF token after successful form submission for enhanced security
*/
private function rotateTokenIfNeeded($request): void
{
$formId = $request->parsedBody->get('_form_id');
if ($formId) {
// Generate a fresh token for the next request
$this->session->csrf->rotateToken($formId);
}
}
}

View File

@@ -0,0 +1,210 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Middlewares;
use App\Framework\Config\WafConfig;
use App\Framework\DateTime\Timer;
use App\Framework\DDoS\DDoSProtectionEngine;
use App\Framework\DDoS\Response\ValueObjects\DDoSResponse;
use App\Framework\DDoS\Response\ValueObjects\ResponseType;
use App\Framework\DDoS\ValueObjects\DDoSAssessment;
use App\Framework\Http\HttpMiddleware;
use App\Framework\Http\HttpResponse;
use App\Framework\Http\MiddlewareContext;
use App\Framework\Http\MiddlewarePriority;
use App\Framework\Http\MiddlewarePriorityAttribute;
use App\Framework\Http\Next;
use App\Framework\Http\RequestStateManager;
use App\Framework\Logging\Logger;
/**
* DDoS Protection Middleware
*
* Provides distributed denial-of-service protection by analyzing traffic patterns,
* geographic anomalies, and coordinating with existing security modules.
* Runs with highest priority before all other security middleware.
*/
#[MiddlewarePriorityAttribute(MiddlewarePriority::SECURITY, 10)] // Higher than standard security
final readonly class DDoSProtectionMiddleware implements HttpMiddleware
{
public function __construct(
private DDoSProtectionEngine $ddosEngine,
private Logger $logger,
private WafConfig $config,
private Timer $timer,
) {
}
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
{
if (! $this->config->enabled || ! $this->config->enableDdosProtection) {
return $next($context);
}
try {
$request = $context->request;
$clientIp = $request->server->getClientIp()?->value ?? '127.0.0.1';
// Skip trusted IPs
if ($this->config->isTrustedIp($clientIp)) {
return $next($context);
}
// Skip exempt paths
if ($this->config->isExemptPath($request->path)) {
return $next($context);
}
// Analyze request for DDoS patterns
$assessment = $this->ddosEngine->analyzeRequest($request);
// Execute appropriate response based on assessment
$ddosResponse = $this->ddosEngine->executeResponse($assessment, $request);
// Log significant threats
if ($assessment->confidence > 0.5) {
$this->logThreatDetection($request, $assessment, $ddosResponse);
}
// Handle response based on type
if ($ddosResponse->shouldBlock()) {
return $this->handleBlockingResponse($context, $ddosResponse);
}
if ($ddosResponse->requiresUserInteraction()) {
return $this->handleChallengeResponse($context, $ddosResponse);
}
if ($ddosResponse->type === ResponseType::TAR_PIT) {
// Implement delay if tar pit response
if (isset($ddosResponse->metadata['delay_seconds'])) {
$this->timer->sleep(min(5, (int)$ddosResponse->metadata['delay_seconds']));
}
}
// For ALLOW responses, continue with the request but add any security headers
$resultContext = $next($context);
if ($resultContext->hasResponse() && $ddosResponse->headers) {
return $this->addSecurityHeaders($resultContext, $ddosResponse);
}
return $resultContext;
} catch (\Throwable $e) {
// Log error but don't block on DDoS engine failure
$this->logger->error('DDoS protection middleware error', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
'request_path' => $context->request->path ?? '/',
'client_ip' => $context->request->server->getClientIp()?->value ?? 'unknown',
]);
return $next($context);
}
}
/**
* Handle blocking response (BLOCK or RATE_LIMITED)
*/
private function handleBlockingResponse(MiddlewareContext $context, DDoSResponse $ddosResponse): MiddlewareContext
{
$request = $context->request;
$clientIp = $request->server->getClientIp()?->value ?? 'unknown';
$this->logger->warning('DDoS request blocked', [
'client_ip' => $clientIp,
'path' => $request->path,
'reason' => $ddosResponse->reason,
'response_type' => $ddosResponse->type->value,
]);
// Convert DDoSResponse to HttpResponse
$httpResponse = new HttpResponse(
$ddosResponse->httpStatus,
$ddosResponse->headers,
$ddosResponse->body
);
return $context->withResponse($httpResponse);
}
/**
* Handle challenge response (CAPTCHA, etc.)
*/
private function handleChallengeResponse(MiddlewareContext $context, DDoSResponse $ddosResponse): MiddlewareContext
{
$request = $context->request;
$clientIp = $request->server->getClientIp()?->value ?? 'unknown';
$this->logger->info('DDoS challenge issued', [
'client_ip' => $clientIp,
'path' => $request->path,
'challenge_type' => $ddosResponse->metadata['challenge_type'] ?? 'unknown',
]);
// Convert DDoSResponse to HttpResponse
$httpResponse = new HttpResponse(
$ddosResponse->httpStatus,
$ddosResponse->headers,
$ddosResponse->body
);
return $context->withResponse($httpResponse);
}
/**
* Add security headers to response
*/
private function addSecurityHeaders(MiddlewareContext $context, DDoSResponse $ddosResponse): MiddlewareContext
{
$response = $context->response;
if ($ddosResponse->headers) {
$updatedHeaders = $response->headers->merge($ddosResponse->headers);
$updatedResponse = new HttpResponse(
$response->status,
$updatedHeaders,
$response->body
);
return $context->withResponse($updatedResponse);
}
return $context;
}
/**
* Log threat detection information
*/
private function logThreatDetection(
$request,
DDoSAssessment $assessment,
DDoSResponse $ddosResponse
): void {
$clientIp = $request->server->getClientIp()?->value ?? 'unknown';
$logLevel = $assessment->isCriticalThreat() ? 'critical' : ($assessment->isHighThreat() ? 'warning' : 'info');
$logData = [
'client_ip' => $clientIp,
'path' => $request->path,
'confidence' => $assessment->confidence,
'threat_level' => $assessment->threatLevel->name,
'threat_indicators' => $assessment->getThreatIndicators(),
'response_type' => $ddosResponse->type->value,
'response_reason' => $ddosResponse->reason,
'processing_time_ms' => $assessment->processingTime->toMilliseconds(),
];
if ($assessment->isCriticalThreat() || $assessment->isHighThreat()) {
$logData['user_agent'] = $request->headers->get('User-Agent', '');
$logData['attack_type'] = $assessment->attackType?->value ?? 'unknown';
$logData['geographic_data'] = $assessment->geographicData ?? [];
}
$this->logger->$logLevel('DDoS threat detected', $logData);
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Middlewares;
use App\Framework\Http\Headers;
use App\Framework\Http\HttpMiddleware;
use App\Framework\Http\HttpResponse;
use App\Framework\Http\MiddlewareContext;
use App\Framework\Http\MiddlewarePriority;
use App\Framework\Http\MiddlewarePriorityAttribute;
use App\Framework\Http\Next;
use App\Framework\Http\RequestStateManager;
use App\Framework\Http\Status;
/**
* Terminal middleware that ensures every request gets a response.
*
* This middleware should be placed at the end of the middleware chain
* to catch any requests that haven't received a response from previous
* middlewares and provide a sensible default (404 Not Found).
*/
#[MiddlewarePriorityAttribute(MiddlewarePriority::FALLBACK)]
final readonly class DefaultResponseMiddleware implements HttpMiddleware
{
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
{
$resultContext = $next($context);
if (! $resultContext->hasResponse()) {
$response = new HttpResponse(
status: Status::NOT_FOUND,
headers: new Headers([
'Content-Type' => 'text/plain; charset=utf-8',
]),
body: 'Not Found'
);
return $resultContext->withResponse($response);
}
return $resultContext;
}
}

View File

@@ -1,72 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Middlewares;
use App\Framework\ErrorHandling\ErrorHandler;
use App\Framework\ErrorHandling\ExceptionConverter;
use App\Framework\Http\HttpMiddleware;
use App\Framework\Http\HttpResponse;
use App\Framework\Http\MiddlewareContext;
use App\Framework\Http\MiddlewarePriority;
use App\Framework\Http\MiddlewarePriorityAttribute;
use App\Framework\Http\Next;
use App\Framework\Http\RequestStateManager;
use App\Framework\Http\RequestIdGenerator;
use App\Framework\Logging\DefaultLogger;
use App\Framework\Validation\Exceptions\ValidationException;
use App\Framework\Logging\Logger;
#[MiddlewarePriorityAttribute(MiddlewarePriority::ERROR_HANDLING)]
final readonly class ExceptionHandlingMiddleware implements HttpMiddleware
{
public function __construct(
private DefaultLogger $logger,
private RequestIdGenerator $requestIdGenerator,
private Logger $logger,
private ErrorHandler $errorHandler,
) {}
) {
}
public function __invoke(MiddlewareContext $context, callable $next, RequestStateManager $stateManager): MiddlewareContext
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
{
try {
return $next($context);
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
$response = $this->errorHandler->createHttpResponse($e, $context);
return $context->withResponse($response);
// Detaillierte Fehlerinformationen loggen
$this->logger->error('Unbehandelte Exception in Middleware-Chain', [
'exception' => $e,
'path' => $context->request->path,
'method' => $context->request->method->value,
]);
// Request-ID für die Antwort holen
$requestId = $this->requestIdGenerator->generate();
// Passende HTTP-Response basierend auf der Exception
$status = ExceptionConverter::getStatusFromException($e);
// Debug-Modus prüfen
$isDebug = filter_var($_ENV['APP_DEBUG'] ?? false, FILTER_VALIDATE_BOOLEAN);
// Response-Body erstellen
$body = ExceptionConverter::getResponseBody(
$e,
$isDebug,
$requestId->getId()
);
$response = new HttpResponse(
status: $status,
body: json_encode($body, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
);
return $context->withResponse($response);
}
}
}

View File

@@ -0,0 +1,326 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Middlewares;
use App\Framework\DI\Container;
use App\Framework\Http\HttpMiddleware;
use App\Framework\Http\MiddlewareContext;
use App\Framework\Http\MiddlewarePriority;
use App\Framework\Http\MiddlewarePriorityAttribute;
use App\Framework\Http\Next;
use App\Framework\Http\RequestStateManager;
use App\Framework\Http\ResponseManipulator;
use App\Framework\Http\Session\FormIdGenerator;
use App\Framework\Http\Session\SessionInterface;
/**
* Middleware that processes HTML responses to replace form-related placeholders
* with actual session data (CSRF tokens, old input values, validation errors).
*
* This solves the template caching issue where StringProcessors only run on first render.
* By processing the response, we can inject dynamic content into cached templates.
*/
#[MiddlewarePriorityAttribute(MiddlewarePriority::ROUTING, -2)]
final readonly class FormDataResponseMiddleware implements HttpMiddleware
{
public function __construct(
private FormIdGenerator $formIdGenerator,
private ResponseManipulator $responseManipulator,
private Container $container,
) {
}
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
{
// Continue to next middleware first
$context = $next($context);
// Only process if we have a response with HTML content
if (! $context->hasResponse()) {
return $context;
}
$response = $context->response;
$contentType = implode(', ', $response?->headers->get('Content-Type') ?? []);
// Check if this is a JSON response (SPA partial)
$isJsonResponse = str_contains($contentType, 'application/json');
// Only process HTML responses or JSON responses with HTML content
if ($contentType && ! str_contains($contentType, 'text/html') && ! str_contains($contentType, 'application/xhtml') && ! $isJsonResponse) {
// If no content type is set, assume HTML (common case)
if ($contentType !== null) {
return $context;
}
}
$body = $response->body;
// Handle JSON responses with HTML content (SPA partials)
if ($isJsonResponse && is_string($body)) {
$jsonData = json_decode($body, true);
if ($jsonData && isset($jsonData['html'])) {
// Process the HTML content within the JSON
$processedHtml = $this->processJsonWithHtml($jsonData, $context, $stateManager);
if ($processedHtml !== null) {
$updatedResponse = $this->responseManipulator->withBody($response, json_encode($processedHtml));
return $context->withResponse($updatedResponse);
}
}
return $context;
}
if (! is_string($body) || empty($body)) {
return $context;
}
// Check if we have any form-related placeholders
$hasToken = str_contains($body, '___TOKEN___');
$hasOldInput = str_contains($body, '___OLD_INPUT_');
$hasError = str_contains($body, '___ERROR_');
if (! $hasToken && ! $hasOldInput && ! $hasError) {
return $context;
}
// Get session from request state (set by SessionMiddleware) or container
$session = $stateManager->get('session');
if (! $session instanceof SessionInterface) {
// Try to get from container as fallback
try {
$session = $this->container->get(SessionInterface::class);
} catch (\Throwable $e) {
return $context;
}
}
$processedBody = $this->processFormPlaceholders($body, $session);
// Update the response with the processed body using ResponseManipulator
$updatedResponse = $this->responseManipulator->withBody($response, $processedBody);
return $context->withResponse($updatedResponse);
}
private function processJsonWithHtml(array $jsonData, MiddlewareContext $context, RequestStateManager $stateManager): ?array
{
if (! isset($jsonData['html']) || ! is_string($jsonData['html'])) {
return null;
}
$html = $jsonData['html'];
// Check if we have any form-related placeholders
$hasToken = str_contains($html, '___TOKEN___');
$hasOldInput = str_contains($html, '___OLD_INPUT_');
$hasError = str_contains($html, '___ERROR_');
if (! $hasToken && ! $hasOldInput && ! $hasError) {
return null;
}
// Get session from request state (set by SessionMiddleware) or container
$session = $stateManager->get('session');
if (! $session instanceof SessionInterface) {
// Try to get from container as fallback
try {
$session = $this->container->get(SessionInterface::class);
} catch (\Throwable $e) {
return null;
}
}
// Process the HTML content
$processedHtml = $this->processFormPlaceholders($html, $session);
// Update the JSON data with processed HTML
$jsonData['html'] = $processedHtml;
return $jsonData;
}
private function processFormPlaceholders(string $html, SessionInterface $session): string
{
$formIds = $this->formIdGenerator->extractFormIdsFromHtml($html);
if (empty($formIds)) {
return $html;
}
// Process each form
foreach ($formIds as $formId) {
// 1. Replace CSRF tokens
if (str_contains($html, '___TOKEN___')) {
$token = $session->csrf->generateToken($formId);
$html = $this->replaceTokenForFormId($html, $formId, $token->toString());
}
// 2. Replace old input values
$html = $this->replaceOldInputForForm($html, $formId, $session);
// 3. Replace error messages
$html = $this->replaceErrorsForForm($html, $formId, $session);
}
return $html;
}
private function replaceTokenForFormId(string $html, string $formId, string $token): string
{
// Find the token placeholder that belongs to the corresponding form ID
// We need to consider the form context
$pattern = '/(<form[^>]*>.*?<input[^>]*name=["\']_form_id["\'][^>]*value=["\']' . preg_quote($formId, '/') . '["\'][^>]*>.*?)<input[^>]*name=["\']_token["\'][^>]*value=["\']___TOKEN___["\'][^>]*>/s';
return preg_replace_callback($pattern, function ($matches) use ($token) {
return str_replace('___TOKEN___', $token, $matches[0]);
}, $html);
}
private function replaceOldInputForForm(string $html, string $formId, SessionInterface $session): string
{
// Use session's form storage property
if (! $session->form->has($formId)) {
// No old data, remove placeholders
return $this->cleanupOldInputPlaceholders($html);
}
$oldData = $session->form->getAndFlash($formId);
foreach ($oldData as $fieldName => $value) {
// Text inputs and textareas
$placeholder = "___OLD_INPUT_{$fieldName}___";
if (str_contains($html, $placeholder)) {
$escapedValue = htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
$html = str_replace($placeholder, $escapedValue, $html);
}
// Select options
if (is_scalar($value)) {
$selectPattern = "___OLD_SELECT_{$fieldName}_{$value}___";
$html = str_replace(
"data-selected-if=\"{$selectPattern}\"",
'selected="selected"',
$html
);
}
// Radio buttons
if (is_scalar($value)) {
$radioPattern = "___OLD_RADIO_{$fieldName}_{$value}___";
$html = str_replace(
"data-checked-if=\"{$radioPattern}\"",
'checked="checked"',
$html
);
}
// Checkboxes
if (is_array($value)) {
foreach ($value as $checkboxValue) {
$checkboxPattern = "___OLD_CHECKBOX_{$fieldName}_{$checkboxValue}___";
$html = str_replace(
"data-checked-if=\"{$checkboxPattern}\"",
'checked="checked"',
$html
);
}
} elseif ($value) {
$checkboxValue = is_bool($value) ? '1' : (string) $value;
$checkboxPattern = "___OLD_CHECKBOX_{$fieldName}_{$checkboxValue}___";
$html = str_replace(
"data-checked-if=\"{$checkboxPattern}\"",
'checked="checked"',
$html
);
}
}
return $this->cleanupOldInputPlaceholders($html);
}
private function replaceErrorsForForm(string $html, string $formId, SessionInterface $session): string
{
// Use session's validation property
if (! $session->validation->has($formId)) {
// No errors, remove error placeholders
return $this->cleanupErrorPlaceholders($html);
}
$errors = $session->validation->getAndFlash($formId);
foreach ($errors as $fieldName => $fieldErrors) {
$placeholder = "___ERROR_{$fieldName}___";
if (! str_contains($html, $placeholder)) {
continue;
}
if (empty($fieldErrors)) {
// Remove error display
$html = $this->removeErrorDisplay($html, $fieldName);
continue;
}
// Build error HTML
$errorHtml = '';
foreach ($fieldErrors as $error) {
$escapedError = htmlspecialchars($error, ENT_QUOTES, 'UTF-8');
$errorHtml .= "<span class=\"error-message\">{$escapedError}</span>";
}
// Replace placeholder with actual errors
$html = str_replace($placeholder, $errorHtml, $html);
// Add error class to the field
$html = $this->addErrorClassToField($html, $fieldName);
}
return $this->cleanupErrorPlaceholders($html);
}
private function cleanupOldInputPlaceholders(string $html): string
{
// Remove remaining old input placeholders
$html = preg_replace('/___OLD_INPUT_[^_]+___/', '', $html);
// Remove remaining data attributes
$html = preg_replace('/\s*data-selected-if="[^"]*"/', '', $html);
$html = preg_replace('/\s*data-checked-if="[^"]*"/', '', $html);
return $html;
}
private function cleanupErrorPlaceholders(string $html): string
{
// Remove empty error displays
$html = preg_replace('/<div[^>]*class="[^"]*error-display[^"]*"[^>]*>___ERROR_[^_]+___<\/div>/', '', $html);
$html = preg_replace('/___ERROR_[^_]+___/', '', $html);
return $html;
}
private function removeErrorDisplay(string $html, string $fieldName): string
{
$pattern = '/<div[^>]*class="[^"]*error-display[^"]*"[^>]*data-field="' . preg_quote($fieldName, '/') . '"[^>]*>.*?<\/div>/s';
return preg_replace($pattern, '', $html);
}
private function addErrorClassToField(string $html, string $fieldName): string
{
// Add 'error' class to field - handle existing class attribute
$pattern = '/(<(?:input|select|textarea)[^>]*name="' . preg_quote($fieldName, '/') . '"[^>]*class="[^"]*)(")(.*?>)/';
$html = preg_replace($pattern, '$1 error$2$3', $html);
// Handle fields without existing class attribute
$pattern = '/(<(?:input|select|textarea)[^>]*name="' . preg_quote($fieldName, '/') . '"[^>]*?)(?:\s|>)/';
$html = preg_replace($pattern, '$1 class="error" ', $html);
return $html;
}
}

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Middlewares;
@@ -8,17 +9,20 @@ use App\Framework\Http\Method;
use App\Framework\Http\MiddlewareContext;
use App\Framework\Http\MiddlewarePriority;
use App\Framework\Http\MiddlewarePriorityAttribute;
use App\Framework\Http\Next;
use App\Framework\Http\Request;
use App\Framework\Http\RequestStateManager;
use Psr\Log\LoggerInterface;
use App\Framework\Logging\Logger;
#[MiddlewarePriorityAttribute(MiddlewarePriority::SECURITY, -140)] // Nach CSRF, vor anderen Validierungen
final readonly class HoneypotMiddleware implements HttpMiddleware
{
public function __construct(
private ?LoggerInterface $logger = null
) {}
private ?Logger $logger = null
) {
}
public function __invoke(MiddlewareContext $context, callable $next, RequestStateManager $stateManager): MiddlewareContext
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
{
$request = $context->request;
@@ -29,20 +33,22 @@ final readonly class HoneypotMiddleware implements HttpMiddleware
return $next($context);
}
private function validateHoneypot($request): void
private function validateHoneypot(Request $request): void
{
$honeypotName = $request->parsedBody->get('_honeypot_name');
if (!$honeypotName) {
if (! $honeypotName) {
$this->logSuspiciousActivity('Missing honeypot name', $request);
throw new \Exception('Spam-Schutz ausgelöst');
}
$honeypotValue = $request->parsedBody->get($honeypotName);
// Honeypot wurde ausgefüllt = Bot erkannt
if (!empty($honeypotValue)) {
if (! empty($honeypotValue)) {
$this->logSuspiciousActivity("Honeypot filled: {$honeypotName} = {$honeypotValue}", $request);
throw new \Exception('Spam-Schutz ausgelöst');
}
@@ -50,26 +56,25 @@ final readonly class HoneypotMiddleware implements HttpMiddleware
$this->validateSubmissionTime($request);
}
private function validateSubmissionTime($request): void
private function validateSubmissionTime(Request $request): void
{
// Formulare, die zu schnell abgeschickt werden, sind verdächtig
$startTime = $request->parsedBody->get('_form_start_time');
if ($startTime && (time() - (int)$startTime) < 2) {
$this->logSuspiciousActivity('Form submitted too quickly', $request);
throw new \Exception('Spam-Schutz ausgelöst');
}
}
private function logSuspiciousActivity(string $reason, $request): void
private function logSuspiciousActivity(string $reason, Request $request): void
{
if ($this->logger) {
$this->logger->warning('Honeypot triggered', [
'reason' => $reason,
'ip' => $request->getClientIp(),
'user_agent' => $request->headers->get('User-Agent') ?? 'unknown',
'url' => $request->uri
]);
}
$this->logger?->warning('Honeypot triggered', [
'reason' => $reason,
'ip' => $request->server->getClientIp()->isPrivate(),
'user_agent' => $request->headers->get('User-Agent') ?? 'unknown',
'url' => $request->path,
]);
}
}

View File

@@ -1,22 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Middlewares;
use App\Framework\Http\HttpMiddleware;
use App\Framework\Http\MiddlewareContext;
use App\Framework\Http\MiddlewarePriority;
use App\Framework\Http\MiddlewarePriorityAttribute;
use App\Framework\Http\Next;
use App\Framework\Http\RequestStateManager;
use App\Framework\Logging\DefaultLogger;
use App\Framework\Logging\Logger;
#[MiddlewarePriorityAttribute(MiddlewarePriority::LOGGING)]
final readonly class LoggingMiddleware implements HttpMiddleware
{
public function __construct(
private DefaultLogger $logger
) {}
private Logger $logger
) {
}
public function __invoke(MiddlewareContext $context, callable $next, RequestStateManager $stateManager): MiddlewareContext
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
{
$start = microtime(true);

View File

@@ -0,0 +1,144 @@
# DDoSProtectionMiddleware Refactoring Summary
## Overview
The DDoSProtectionMiddleware has been refactored to correctly use the DDoS module's functionality, particularly the AdaptiveResponseSystem. This document summarizes the changes made, the benefits of the new implementation, and recommendations for testing and future improvements.
## Key Changes
### 1. Leveraging the AdaptiveResponseSystem
The middleware now uses the `executeResponse` method from the DDoSProtectionEngine, which delegates to the AdaptiveResponseSystem to generate appropriate responses based on the threat assessment:
```php
// Execute appropriate response based on assessment
$ddosResponse = $this->ddosEngine->executeResponse($assessment, $request);
```
### 2. Response Type-Based Handling
The middleware now handles responses based on their type, using specialized methods for different response types:
```php
// Handle response based on type
if ($ddosResponse->shouldBlock()) {
return $this->handleBlockingResponse($context, $ddosResponse);
}
if ($ddosResponse->requiresUserInteraction()) {
return $this->handleChallengeResponse($context, $ddosResponse);
}
if ($ddosResponse->type === ResponseType::TAR_PIT) {
// Implement delay if tar pit response
if (isset($ddosResponse->metadata['delay_seconds'])) {
sleep(min(5, (int)$ddosResponse->metadata['delay_seconds']));
}
}
```
### 3. Proper Type Hints
Added proper type hints for all parameters, improving type safety and code readability:
```php
private function handleBlockingResponse(MiddlewareContext $context, DDoSResponse $ddosResponse): MiddlewareContext
```
### 4. Improved Logging
Enhanced logging with dynamic log levels based on the threat level and more comprehensive log data:
```php
private function logThreatDetection(
$request,
DDoSAssessment $assessment,
DDoSResponse $ddosResponse
): void {
$clientIp = $request->server->getClientIp()?->value ?? 'unknown';
$logLevel = $assessment->isCriticalThreat() ? 'critical' : ($assessment->isHighThreat() ? 'warning' : 'info');
$logData = [
'client_ip' => $clientIp,
'path' => $request->path,
'confidence' => $assessment->confidence,
'threat_level' => $assessment->threatLevel->name,
'threat_indicators' => $assessment->getThreatIndicators(),
'response_type' => $ddosResponse->type->value,
'response_reason' => $ddosResponse->reason,
'processing_time_ms' => $assessment->processingTime->toMilliseconds(),
];
// Additional data for high and critical threats
if ($assessment->isCriticalThreat() || $assessment->isHighThreat()) {
$logData['user_agent'] = $request->headers->get('User-Agent', '');
$logData['attack_type'] = $assessment->attackType?->value ?? 'unknown';
$logData['geographic_data'] = $assessment->geographicData ?? [];
}
$this->logger->$logLevel('DDoS threat detected', $logData);
}
```
### 5. Simplified Code
Removed redundant code that was duplicating functionality already provided by the DDoS module, reducing the middleware from 252 lines to 210 lines.
## Benefits of the New Implementation
1. **Better Separation of Concerns**: The middleware now focuses on HTTP request/response handling, while delegating DDoS protection logic to the specialized DDoS module.
2. **More Comprehensive Protection**: By using the AdaptiveResponseSystem, the middleware now supports a wider range of response types, including CAPTCHA challenges and tar pit responses.
3. **Improved Maintainability**: The code is now more modular, with specialized methods for different response types, making it easier to maintain and extend.
4. **Enhanced Logging**: The logging is now more comprehensive and uses the appropriate log level based on the threat level.
5. **Better Type Safety**: Proper type hints for all parameters improve code readability and help catch errors at compile time.
## Testing Recommendations
To ensure the refactored middleware works correctly, the following tests should be performed:
### 1. Unit Tests
Create unit tests for the middleware that mock the DDoSProtectionEngine and verify that:
- The middleware correctly handles different response types (BLOCK, RATE_LIMITED, CHALLENGE, TAR_PIT, ALLOW)
- The middleware correctly converts DDoSResponse objects to HttpResponse objects
- The middleware correctly adds security headers to allowed responses
- The middleware correctly logs threat detection information
- The middleware correctly handles errors from the DDoS module
### 2. Integration Tests
Create integration tests that use the actual DDoSProtectionEngine and verify that:
- The middleware correctly integrates with the DDoS module
- The middleware correctly handles different threat levels
- The middleware correctly handles different attack patterns
- The middleware correctly handles trusted IPs and exempt paths
### 3. Performance Tests
Create performance tests to verify that:
- The middleware does not add significant overhead to request processing
- The middleware correctly implements delays for tar pit responses
- The middleware correctly handles high load scenarios
## Future Improvements
1. **Asynchronous Processing**: Consider implementing asynchronous processing for non-blocking operations like logging and learning updates.
2. **Circuit Breaker**: Add a circuit breaker to prevent the middleware from becoming a bottleneck during high load scenarios.
3. **Metrics Collection**: Add metrics collection to monitor the middleware's performance and effectiveness.
4. **Configuration Options**: Add more configuration options to fine-tune the middleware's behavior.
5. **Response Caching**: Implement caching for responses to improve performance for repeated requests.
## Conclusion
The refactored DDoSProtectionMiddleware now correctly uses the DDoS module's functionality, providing more comprehensive protection against DDoS attacks while maintaining good performance and maintainability. The middleware is now more modular, with specialized methods for different response types, making it easier to maintain and extend.

View File

@@ -0,0 +1,161 @@
<?php
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;
use App\Framework\Http\HttpResponse;
use App\Framework\Http\MiddlewareContext;
use App\Framework\Http\MiddlewarePriority;
use App\Framework\Http\MiddlewarePriorityAttribute;
use App\Framework\Http\Next;
use App\Framework\Http\RequestStateManager;
use App\Framework\Http\Response;
use App\Framework\Http\ResponseManipulator;
use App\Framework\Http\Status;
use App\Framework\RateLimit\RateLimitConfig;
use App\Framework\RateLimit\RateLimiter;
use App\Framework\RateLimit\RateLimitResult;
/**
* Modern Rate Limiting Middleware using the Framework's RateLimit system
*
* Supports both sliding window and token bucket algorithms.
* Provides comprehensive rate limiting with security event logging.
*/
#[MiddlewarePriorityAttribute(MiddlewarePriority::RATE_LIMITING)]
final readonly class RateLimitMiddleware implements HttpMiddleware
{
public function __construct(
private RateLimiter $rateLimiter,
private ResponseManipulator $responseManipulator,
private RateLimitConfig $config = new RateLimitConfig(),
private ?SecurityEventLogger $securityLogger = null
) {
}
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
{
if (! $this->config->enabled) {
return $next($context);
}
$request = $context->request;
$clientIp = $this->getClientIp($request);
// Check exemptions
if ($this->isExempt($clientIp, $request->path)) {
return $next($context);
}
// Check rate limits
$result = $this->checkRateLimit($clientIp, $request->path);
if ($result->isExceeded()) {
return $this->handleRateLimitExceeded($context, $clientIp, $request->path, $result);
}
// Proceed with request and add rate limit headers
$resultContext = $next($context);
if ($resultContext->hasResponse()) {
$response = $this->addRateLimitHeaders($resultContext->response, $result);
return $resultContext->withResponse($response);
}
return $resultContext;
}
private function getClientIp($request): string
{
// Try to get from server environment or fallback
$serverIp = $request->server->getClientIp();
return $serverIp ? $serverIp->value : '127.0.0.1';
}
private function isExempt(string $clientIp, string $path): bool
{
return $this->config->isTrustedIp($clientIp) ||
$this->config->isExemptPath($path);
}
private function checkRateLimit(string $clientIp, string $path): RateLimitResult
{
$limit = $this->config->requestsPerMinute;
$window = $this->config->windowSize;
// Generate rate limit key
$key = $this->generateRateLimitKey($clientIp, $path);
// Use sliding window algorithm by default
return $this->rateLimiter->checkLimit($key, $limit, $window);
}
private function generateRateLimitKey(string $clientIp, string $path): string
{
// Simple key generation based on IP and path
return "ratelimit:ip:{$clientIp}:path:" . md5($path);
}
private function handleRateLimitExceeded(
MiddlewareContext $context,
string $clientIp,
string $path,
RateLimitResult $result
): MiddlewareContext {
// Log security event
$this->logSecurityEvent($clientIp, $path, $result);
// Create rate limit exceeded response
$headers = new Headers([
'Content-Type' => 'application/json',
'X-RateLimit-Limit' => (string) $result->getLimit(),
'X-RateLimit-Remaining' => '0',
'X-RateLimit-Reset' => (string) (time() + ($result->getRetryAfter() ?? $this->config->windowSize)),
'Retry-After' => (string) ($result->getRetryAfter() ?? $this->config->windowSize),
]);
$body = json_encode([
'error' => 'Rate limit exceeded',
'message' => 'Too many requests. Please slow down.',
'limit' => $result->getLimit(),
'current' => $result->getCurrent(),
'retry_after' => $result->getRetryAfter() ?? $this->config->windowSize,
], JSON_PRETTY_PRINT);
$response = new HttpResponse(Status::TOO_MANY_REQUESTS, $headers, $body);
return $context->withResponse($response);
}
private function addRateLimitHeaders(Response $response, RateLimitResult $result): Response
{
$headers = $response->headers
->with('X-RateLimit-Limit', (string) $result->getLimit())
->with('X-RateLimit-Remaining', (string) $result->getRemainingRequests())
->with('X-RateLimit-Reset', (string) (time() + $this->config->windowSize));
return $this->responseManipulator->withHeaders($response, $headers);
}
private function logSecurityEvent(string $clientIp, string $path, RateLimitResult $result): void
{
if (! $this->securityLogger) {
return;
}
$event = new SystemExcessiveUseEvent(
null, // No user ID for IP-based limiting
$clientIp,
"Rate limit exceeded for {$path}: {$result->getCurrent()}/{$result->getLimit()} requests"
);
$this->securityLogger->log($event);
}
}

View File

@@ -1,172 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Middlewares;
use App\Framework\Http\Headers;
use App\Framework\Http\HttpMiddleware;
use App\Framework\Http\HttpResponse;
use App\Framework\Http\MiddlewareContext;
use App\Framework\Http\MiddlewarePriority;
use App\Framework\Http\MiddlewarePriorityAttribute;
use App\Framework\Http\Request;
use App\Framework\Http\Status;
use Predis\Client as RedisClient;
/**
* Middleware zum Schutz vor zu vielen Anfragen von einer IP-Adresse.
* Verwendet Redis zum Zählen der Anfragen und implementiert ein Sliding Window Limiting.
*/
#[MiddlewarePriorityAttribute(MiddlewarePriority::SECURITY)]
final class RateLimitingMiddleware implements XHttpMiddleware
{
private const string REDIS_KEY_PREFIX = 'rate_limit:';
/**
* @param RedisClient $redis Redis-Client zum Speichern der Zähler
* @param int $defaultLimit Standardmäßige Anzahl an erlaubten Anfragen im Zeitfenster
* @param int $windowSeconds Länge des Zeitfensters in Sekunden
* @param array<string, array{limit: int, window: int}> $pathLimits Spezifische Limits für bestimmte Pfade
*/
public function __construct(
private readonly RedisClient $redis,
private readonly int $defaultLimit = 60,
private readonly int $windowSeconds = 60,
private readonly array $pathLimits = []
) {
}
public function __invoke(MiddlewareContext $context, callable $next): MiddlewareContext
{
$clientIp = $this->getClientIp($context->request);
$path = $context->request->path;
// Limits für den aktuellen Pfad ermitteln
[$limit, $window] = $this->getLimitsForPath($path);
// Redis-Schlüssel für diese IP und diesen Pfad
$redisKey = $this->getRedisKey($clientIp, $path);
// Anfrage zählen und prüfen
$requestCount = $this->countRequest($redisKey, $window);
// Rate-Limiting-Header zur Antwort hinzufügen
$remaining = max(0, $limit - $requestCount);
$context = $context->withResponseHeader('X-RateLimit-Limit', (string)$limit)
->withResponseHeader('X-RateLimit-Remaining', (string)$remaining)
->withResponseHeader('X-RateLimit-Reset', (string)$this->redis->ttl($redisKey));
// Wenn das Limit überschritten wurde, 429-Antwort zurückgeben
if ($requestCount > $limit) {
$response = new HttpResponse(
Status::TOO_MANY_REQUESTS,
new Headers(['Content-Type' => 'application/json'])
);
// Retry-After Header hinzufügen
$retryAfter = $this->redis->ttl($redisKey);
$response = $response->withHeader('Retry-After', (string)$retryAfter);
// JSON-Antwort mit Fehlermeldung
$errorResponse = [
'error' => 'Zu viele Anfragen',
'message' => 'Sie haben das Limit für Anfragen überschritten. Bitte versuchen Sie es später erneut.',
'retry_after' => $retryAfter
];
$response->getBody()->write(json_encode($errorResponse));
return $context->withResponse($response);
}
// Anfrage weiterleiten
return $next($context);
}
/**
* Zählt eine Anfrage und gibt die aktuelle Anzahl zurück.
*
* @param string $key Der Redis-Schlüssel für den Counter
* @param int $window Das Zeitfenster in Sekunden
* @return int Die aktuelle Anzahl an Anfragen im Zeitfenster
*/
private function countRequest(string $key, int $window): int
{
// Aktuelle Zeit als Score (für Sorted Set)
$now = time();
// Alten Einträge entfernen (älter als das Zeitfenster)
$this->redis->zremrangebyscore($key, 0, $now - $window);
// Neue Anfrage hinzufügen
$this->redis->zadd($key, [$now => $now]);
// Gesamtanzahl der Anfragen im Zeitfenster ermitteln
$count = $this->redis->zcard($key);
// TTL für den Key setzen (Aufräumen)
$this->redis->expire($key, $window);
return (int)$count;
}
/**
* Ermittelt die Limits für einen bestimmten Pfad.
*
* @param string $path Der Pfad
* @return array{int, int} [limit, window]
*/
private function getLimitsForPath(string $path): array
{
// Prüfen auf spezifische Pfad-Limits
foreach ($this->pathLimits as $pattern => $config) {
if (preg_match($pattern, $path)) {
return [$config['limit'], $config['window']];
}
}
// Standardwerte zurückgeben
return [$this->defaultLimit, $this->windowSeconds];
}
/**
* Generiert den Redis-Schlüssel für eine IP und einen Pfad.
*/
private function getRedisKey(string $ip, string $path): string
{
// Für allgemeines Rate-Limiting alle Pfade gleich behandeln
if (empty($this->pathLimits)) {
return self::REDIS_KEY_PREFIX . $ip;
}
// Für pfad-spezifisches Rate-Limiting einen pfad-spezifischen Schlüssel erstellen
$pathHash = md5($path);
return self::REDIS_KEY_PREFIX . "{$ip}:{$pathHash}";
}
/**
* Ermittelt die IP-Adresse des Clients unter Berücksichtigung von Proxies.
*/
private function getClientIp(Request $request): string
{
$headers = [
'X-Forwarded-For',
'X-Real-IP',
'HTTP_X_FORWARDED_FOR',
'HTTP_CLIENT_IP',
'REMOTE_ADDR'
];
foreach ($headers as $header) {
$value = $request->headers->get($header)[0] ?? null;
if ($value) {
// Bei mehreren IPs (X-Forwarded-For kann mehrere enthalten) die erste nehmen
$ips = explode(',', $value);
return trim($ips[0]);
}
}
return '0.0.0.0';
}
}

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Middlewares;
@@ -7,6 +8,7 @@ use App\Framework\Http\HttpMiddleware;
use App\Framework\Http\MiddlewareContext;
use App\Framework\Http\MiddlewarePriority;
use App\Framework\Http\MiddlewarePriorityAttribute;
use App\Framework\Http\Next;
use App\Framework\Http\RequestStateManager;
use App\Framework\Http\ResponseManipulator;
@@ -15,9 +17,10 @@ final readonly class RemovePoweredByMiddleware implements HttpMiddleware
{
public function __construct(
private ResponseManipulator $manipulator
) {}
) {
}
public function __invoke(MiddlewareContext $context, callable $next, RequestStateManager $stateManager): MiddlewareContext
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
{
// Nächste Middleware aufrufen
$resultContext = $next($context);

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Middlewares;
@@ -7,8 +8,9 @@ use App\Framework\Http\HttpMiddleware;
use App\Framework\Http\MiddlewareContext;
use App\Framework\Http\MiddlewarePriority;
use App\Framework\Http\MiddlewarePriorityAttribute;
use App\Framework\Http\RequestStateManager;
use App\Framework\Http\Next;
use App\Framework\Http\RequestIdGenerator;
use App\Framework\Http\RequestStateManager;
use App\Framework\Http\ResponseManipulator;
/**
@@ -21,12 +23,13 @@ final readonly class RequestIdMiddleware implements HttpMiddleware
public function __construct(
private RequestIdGenerator $requestIdGenerator,
private ResponseManipulator $manipulator
) {}
) {
}
public function __invoke(MiddlewareContext $context, callable $next, RequestStateManager $stateManager): MiddlewareContext
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
{
// Request-ID generieren/validieren
$requestId = $this->requestIdGenerator->generate();
$requestId = $context->request->id; #$this->requestIdGenerator->generate();
// Request-ID an den weiteren Verarbeitungskontext weitergeben
$resultContext = $next($context);
@@ -34,7 +37,7 @@ final readonly class RequestIdMiddleware implements HttpMiddleware
// Request-ID in die Response-Header setzen, falls eine Response vorhanden ist
if ($resultContext->hasResponse()) {
$response = $resultContext->response;
$headers = $response->headers->with(
$headers = $response?->headers->with(
$this->requestIdGenerator::getHeaderName(),
$requestId->toString()
);

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Middlewares;
@@ -7,9 +8,8 @@ use App\Framework\Http\HttpMiddleware;
use App\Framework\Http\MiddlewareContext;
use App\Framework\Http\MiddlewarePriority;
use App\Framework\Http\MiddlewarePriorityAttribute;
use App\Framework\Http\Next;
use App\Framework\Http\RequestStateManager;
use App\Framework\Http\Request;
use App\Framework\Http\Response;
use App\Framework\Logging\Logger;
#[MiddlewarePriorityAttribute(MiddlewarePriority::LOGGING)]
@@ -17,8 +17,10 @@ final readonly class RequestLoggingMiddleware implements HttpMiddleware
{
public function __construct(
private Logger $logger
){}
public function __invoke(MiddlewareContext $context, callable $next, RequestStateManager $stateManager): MiddlewareContext
) {
}
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
{
$startTime = microtime(true);

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Middlewares;
@@ -7,7 +8,9 @@ use App\Framework\Http\HttpMiddleware;
use App\Framework\Http\MiddlewareContext;
use App\Framework\Http\MiddlewarePriority;
use App\Framework\Http\MiddlewarePriorityAttribute;
use App\Framework\Http\Next;
use App\Framework\Http\RequestStateManager;
use App\Framework\Http\StateKey;
use App\Framework\Router\RouteResponder;
#[MiddlewarePriorityAttribute(MiddlewarePriority::ROUTING, -1)]
@@ -15,50 +18,26 @@ final readonly class ResponseGeneratorMiddleware implements HttpMiddleware
{
public function __construct(
private RouteResponder $responder,
) {}
) {
}
public function __invoke(MiddlewareContext $context, callable $next, RequestStateManager $stateManager): MiddlewareContext
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
{
$request = $context->request;
if($stateManager->has('controllerResult')) {
$originalResponse = $this->responder->respond($stateManager->get('controllerResult'));
if ($stateManager->has(StateKey::CONTROLLER_RESULT)) {
$controllerResult = $stateManager->get(StateKey::CONTROLLER_RESULT);
$originalResponse = $this->responder->respond($controllerResult);
// Kontext mit der generierten Response aktualisieren
$updatedContext = $context->withResponse($originalResponse);
$resultContext = $next($updatedContext);
if(!$resultContext->hasResponse() && $originalResponse !== null) {
if (! $resultContext->hasResponse()) {
return $resultContext->withResponse($originalResponse);
}
return $resultContext;
}
/*// Prüfen, ob es sich um einen angereicherten Request handelt
if ($request instanceof EnrichedRequest && $request->result) {
// Response aus dem Controller-Ergebnis generieren
$originalResponse = $this->responder->respond($request->result);
// Kontext mit der generierten Response aktualisieren
$updatedContext = $context->withResponse($originalResponse);
$resultContext = $next($updatedContext);
if(!$resultContext->hasResponse() && $originalResponse !== null) {
return $resultContext->withResponse($originalResponse);
}
return $resultContext;
}*/
// Immer die nächste Middleware aufrufen, auch wenn bereits eine Response generiert wurde
return $next($context);
}
}

View File

@@ -1,33 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Middlewares;
use App\Framework\Auth\Attributes\IpAuth;
use App\Framework\Auth\Auth;
use App\Framework\Auth\ValueObjects\IpAuthPolicy;
use App\Framework\Config\TypedConfiguration;
use App\Framework\Http\HttpMiddleware;
use App\Framework\Http\IpAddress;
use App\Framework\Http\MiddlewareContext;
use App\Framework\Http\MiddlewarePriority;
use App\Framework\Http\MiddlewarePriorityAttribute;
use App\Framework\Http\Next;
use App\Framework\Http\RequestStateManager;
use App\Framework\Http\StateKey;
use App\Framework\Meta\MetaData;
use App\Framework\Performance\Contracts\PerformanceServiceInterface;
use App\Framework\Performance\PerformanceCategory;
use App\Framework\Router\ActionResult;
use App\Framework\Router\Exception\RouteNotFound;
use App\Framework\Router\HttpRouter;
use App\Framework\Router\Result\ContentNegotiationResult;
use App\Framework\Router\Result\JsonResult;
use App\Framework\Router\Result\Redirect;
use App\Framework\Router\Result\ViewResult;
use App\Framework\Router\RouteDispatcher;
use App\Framework\Router\Router;
#[MiddlewarePriorityAttribute(MiddlewarePriority::ROUTING)]
final readonly class RoutingMiddleware implements HttpMiddleware
{
public function __construct(
private HttpRouter $router,
private Router $router,
private RouteDispatcher $dispatcher,
private TypedConfiguration $config,
) {}
private PerformanceServiceInterface $performanceService,
private array $namespaceConfig = []
) {
}
/**
* Invokes the middleware logic to process a request and response through the provided context.
@@ -35,51 +46,87 @@ final readonly class RoutingMiddleware implements HttpMiddleware
* Then, it updates the context with the enriched request and passes it to the next middleware.
*
* @param MiddlewareContext $context The context containing the request and response.
* @param callable $next The next middleware to invoke in the chain.
* @param Next $next The next middleware to invoke in the chain.
* @param RequestStateManager $stateManager
* @return MiddlewareContext The updated middleware context after processing.
* @throws RouteNotFound If the route cannot be matched in the current context.
*/
public function __invoke(MiddlewareContext $context, callable $next, RequestStateManager $stateManager): MiddlewareContext
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
{
$request = $context->request;
$routeContext = $this->router->match($request);
error_log("DEBUG RoutingMiddleware: __invoke() called at the very beginning");
if (!$routeContext->isSuccess()) {
$request = $context->request;
// DEBUG: Log routing attempt
error_log("DEBUG RoutingMiddleware: Attempting to match route - Method: {$request->method->value}, Path: {$request->path}");
error_log("DEBUG RoutingMiddleware: About to call router->match()");
try {
// Measure route matching
$routeContext = $this->performanceService->measure(
'route_matching',
fn () => $this->router->match($request),
PerformanceCategory::ROUTING,
[
'method' => $request->method->value,
'path' => $request->path,
]
);
error_log("DEBUG RoutingMiddleware: router->match() completed successfully");
} catch (\Throwable $e) {
error_log("DEBUG RoutingMiddleware: router->match() threw exception: " . $e->getMessage());
throw $e;
}
// DEBUG: Log route matching result
error_log("DEBUG RoutingMiddleware: Route matching result - Success: " . ($routeContext->isSuccess() ? 'true' : 'false'));
if (! $routeContext->isSuccess()) {
error_log("DEBUG RoutingMiddleware: Route not found for path: {$routeContext->path}");
} else {
error_log("DEBUG RoutingMiddleware: Route found - Controller: " . get_class($routeContext->match->route));
}
if (! $routeContext->isSuccess()) {
throw new RouteNotFound($routeContext->path);
}
if (!$this->config->app->debug && in_array(Auth::class, $routeContext->match->route->attributes)) {
// Perform IP and namespace-based authentication
$this->performAuthenticationChecks($request, $routeContext);
#debug($request->server->getClientIp());
$wireguardIp = '172.20.0.1';
// Measure controller dispatch
$controllerName = $routeContext->match->route->controller . '::' . $routeContext->match->route->action;
$ip = $request->server->getClientIp();
if ($ip->value !== $wireguardIp) {
throw new RouteNotFound($routeContext->path);
}
}
$result = $this->performanceService->measure(
'controller_dispatch',
fn () => $this->dispatcher->dispatch($routeContext),
PerformanceCategory::CONTROLLER,
[
'controller' => $controllerName,
'route_name' => $routeContext->match->route->name ?? 'unnamed',
]
);
// Controller-Logik ausführen
$result = $this->dispatcher->dispatch($routeContext);
// Also track individual controller performance
$this->performanceService->increment(
"controller_calls_{$controllerName}",
1,
PerformanceCategory::CONTROLLER
);
if ($result instanceof ContentNegotiationResult) {
$result = $this->contentNegotiation($result);
$result = $this->performanceService->measure(
'content_negotiation',
fn () => $this->contentNegotiation($result),
PerformanceCategory::ROUTING
);
}
$stateManager->set('controllerResult', $result);
// Erstellen eines angereicherten Requests mit dem Controller-Ergebnis
#$enrichedRequest = new EnrichedRequest($request, $result);
// Kontext mit dem angereicherten Request aktualisieren
#$updatedContext = new MiddlewareContext($enrichedRequest, $context->response);
$updatedContext = $context;
$stateManager->set(StateKey::CONTROLLER_RESULT, $result);
// Nächste Middleware aufrufen
return $next($updatedContext);
return $next($context);
}
private function contentNegotiation(ContentNegotiationResult $response): ActionResult
@@ -93,9 +140,163 @@ final readonly class RoutingMiddleware implements HttpMiddleware
return new Redirect($response->redirectTo);
}
if ($response->viewTemplate !== null) {
return new ViewResult(template: $response->viewTemplate,metaData: new MetaData(''), data: $response->viewData);
return new ViewResult(template: $response->viewTemplate, metaData: new MetaData(''), data: $response->viewData);
}
// Optional: Fallback, z.B. Fehler- oder Defaultseite
return new JsonResult(['message' => 'Not found']);
}
/**
* Perform IP and namespace-based authentication checks
*/
private function performAuthenticationChecks($request, $routeContext): void
{
$clientIp = $this->getClientIp($request);
$controllerClass = $routeContext->match->route->controller;
// Check legacy Auth attribute (backward compatibility)
if (! $this->config->app->debug && in_array(Auth::class, $routeContext->match->route->attributes)) {
$wireguardIp = '172.20.0.1';
if ($clientIp->value !== $wireguardIp) {
throw new RouteNotFound($routeContext->path);
}
}
// Check namespace-based restrictions
$namespacePolicy = $this->getNamespacePolicy($controllerClass);
if ($namespacePolicy && ! $namespacePolicy->isAllowed($clientIp)) {
throw new RouteNotFound($routeContext->path);
}
// Check route-specific IP auth attributes
$ipAuthAttribute = $this->getIpAuthAttribute($routeContext->match->route);
if ($ipAuthAttribute) {
$routePolicy = $ipAuthAttribute->createPolicy();
if (! $routePolicy->isAllowed($clientIp)) {
throw new RouteNotFound($routeContext->path);
}
}
}
/**
* Get client IP address
*/
private function getClientIp($request): IpAddress
{
return $request->server->getClientIp() ?? IpAddress::localhost();
}
/**
* Get namespace-based IP policy
*/
private function getNamespacePolicy(string $controllerClass): ?IpAuthPolicy
{
$namespace = $this->extractNamespace($controllerClass);
foreach ($this->namespaceConfig as $pattern => $config) {
if ($this->namespaceMatches($namespace, $pattern)) {
return $this->createPolicyFromConfig($config);
}
}
return null;
}
/**
* Extract namespace from class name
*/
private function extractNamespace(string $className): string
{
$parts = explode('\\', $className);
array_pop($parts); // Remove class name
return implode('\\', $parts);
}
/**
* Check if namespace matches pattern
*/
private function namespaceMatches(string $namespace, string $pattern): bool
{
// Exact match
if ($namespace === $pattern) {
return true;
}
// Wildcard pattern (e.g., "App\Admin\*")
if (str_ends_with($pattern, '*')) {
$prefix = rtrim($pattern, '*');
return str_starts_with($namespace, $prefix);
}
// Prefix match
return str_starts_with($namespace, $pattern);
}
/**
* Create policy from configuration
*/
private function createPolicyFromConfig(array $config): IpAuthPolicy
{
$visibility = $config['visibility'] ?? 'public';
return match ($visibility) {
'admin' => IpAuthPolicy::adminOnly(),
'local' => IpAuthPolicy::localOnly(),
'development' => IpAuthPolicy::development(),
'private' => IpAuthPolicy::localOnly(),
'custom' => IpAuthPolicy::fromConfig($config),
default => IpAuthPolicy::fromConfig([]) // No restrictions for 'public'
};
}
/**
* Get IpAuth attribute from route
*/
private function getIpAuthAttribute($route): ?IpAuth
{
// Check if route has IpAuth attribute in its attributes array
foreach ($route->attributes ?? [] as $attribute) {
if ($attribute instanceof IpAuth) {
return $attribute;
}
}
// Also check via reflection for method-level attributes
try {
$reflection = new \ReflectionMethod($route->controller, $route->action);
$attributes = $reflection->getAttributes(IpAuth::class);
if (! empty($attributes)) {
return $attributes[0]->newInstance();
}
// Check controller class for IpAuth attribute
$classReflection = new \ReflectionClass($route->controller);
$classAttributes = $classReflection->getAttributes(IpAuth::class);
if (! empty($classAttributes)) {
return $classAttributes[0]->newInstance();
}
} catch (\ReflectionException $e) {
// Ignore reflection errors
}
return null;
}
/**
* Create routing middleware with namespace configuration
*/
public static function withNamespaceConfig(
Router $router,
RouteDispatcher $dispatcher,
TypedConfiguration $config,
PerformanceServiceInterface $performanceService,
array $namespaceConfig
): self {
return new self($router, $dispatcher, $config, $performanceService, $namespaceConfig);
}
}

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Middlewares;
@@ -15,7 +16,8 @@ final readonly class SecurityHeaderConfig
public string $crossOriginOpenerPolicy = 'same-origin',
public string $crossOriginResourcePolicy = 'same-origin',
public bool $enableInDevelopment = false
) {}
) {
}
/**
* Erstellt eine Konfiguration für Entwicklungsumgebung mit weniger restriktiven Einstellungen

View File

@@ -1,12 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Middlewares;
use App\Framework\Config\SecurityConfig;
use App\Framework\Http\HttpMiddleware;
use App\Framework\Http\MiddlewareContext;
use App\Framework\Http\MiddlewarePriority;
use App\Framework\Http\MiddlewarePriorityAttribute;
use App\Framework\Http\Next;
use App\Framework\Http\RequestStateManager;
use App\Framework\Http\ResponseManipulator;
@@ -15,22 +18,23 @@ final readonly class SecurityHeaderMiddleware implements HttpMiddleware
{
public function __construct(
private ResponseManipulator $manipulator,
private SecurityHeaderConfig $config = new SecurityHeaderConfig()
) {}
private SecurityConfig $securityConfig
) {
}
public function __invoke(MiddlewareContext $context, callable $next, RequestStateManager $stateManager): MiddlewareContext
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
{
$this->removePoweredByHeader();
// Nächste Middleware aufrufen
$resultContext = $next($context);
// Wenn eine Response vorhanden ist, Security-Header hinzufügen
// Security-Header IMMER hinzufügen (auch in Development)
if ($resultContext->hasResponse()) {
$response = $resultContext->response;
$updatedHeaders = $response->headers;
// Alle Security-Header hinzufügen
// Environment-spezifische Security-Header hinzufügen
foreach ($this->getSecurityHeaders() as $name => $value) {
if ($this->shouldAddHeader($name, $updatedHeaders)) {
$updatedHeaders = $updatedHeaders->with($name, $value);
@@ -48,29 +52,34 @@ final readonly class SecurityHeaderMiddleware implements HttpMiddleware
private function getSecurityHeaders(): array
{
// Environment-spezifische Header-Konfiguration
$config = $this->securityConfig->enableStrictMode
? SecurityHeaderConfig::forProduction()
: SecurityHeaderConfig::forDevelopment();
return [
'Strict-Transport-Security' => $this->config->hstsHeader,
'X-Frame-Options' => $this->config->frameOptions,
'Strict-Transport-Security' => $config->hstsHeader,
'X-Frame-Options' => $config->frameOptions,
'X-Content-Type-Options' => 'nosniff',
'Referrer-Policy' => $this->config->referrerPolicy,
'Content-Security-Policy' => $this->config->contentSecurityPolicy,
'Permissions-Policy' => $this->config->permissionsPolicy,
'Referrer-Policy' => $config->referrerPolicy,
'Content-Security-Policy' => $config->contentSecurityPolicy,
'Permissions-Policy' => $config->permissionsPolicy,
'X-Permitted-Cross-Domain-Policies' => 'none',
'Cross-Origin-Embedder-Policy' => $this->config->crossOriginEmbedderPolicy,
'Cross-Origin-Opener-Policy' => $this->config->crossOriginOpenerPolicy,
'Cross-Origin-Resource-Policy' => $this->config->crossOriginResourcePolicy,
'Cross-Origin-Embedder-Policy' => $config->crossOriginEmbedderPolicy,
'Cross-Origin-Opener-Policy' => $config->crossOriginOpenerPolicy,
'Cross-Origin-Resource-Policy' => $config->crossOriginResourcePolicy,
];
}
private function shouldAddHeader(string $headerName, $currentHeaders): bool
{
// Header nur hinzufügen, wenn er noch nicht gesetzt ist
return !$currentHeaders->has($headerName);
return ! $currentHeaders->has($headerName);
}
private function removePoweredByHeader():void
private function removePoweredByHeader(): void
{
if (!headers_sent()) {
if (! headers_sent()) {
header_remove('X-Powered-By');
header_remove('Server');
}

View File

@@ -1,22 +1,30 @@
<?php
namespace App\Framework\Http\Middleware;
declare(strict_types=1);
namespace App\Framework\Http\Middlewares;
use App\Framework\Core\PathProvider;
use App\Framework\DateTime\Clock;
use App\Framework\Filesystem\Storage;
use App\Framework\Http\Headers;
use App\Framework\Http\HttpMiddleware;
use App\Framework\Http\HttpResponse;
use App\Framework\Http\Middleware;
use App\Framework\Http\Request;
use App\Framework\Http\MiddlewareContext;
use App\Framework\Http\Next;
use App\Framework\Http\RequestStateManager;
use App\Framework\Http\Status;
class ServeStaticFilesMiddleware implements Middleware
class ServeStaticFilesMiddleware implements HttpMiddleware
{
/** @var array<string> */
private array $allowedExtensions = [
'jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'svg',
'css', 'js', 'woff', 'woff2', 'ttf', 'eot',
'pdf', 'ico', 'xml', 'json'
'pdf', 'ico', 'xml', 'json',
];
/** @var array<string, string> */
private array $mimeTypes = [
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
@@ -34,25 +42,31 @@ class ServeStaticFilesMiddleware implements Middleware
'pdf' => 'application/pdf',
'ico' => 'image/x-icon',
'xml' => 'application/xml',
'json' => 'application/json'
'json' => 'application/json',
];
public function __construct(
private PathProvider $pathProvider,
private string $mediaPrefix = '/media'
) {}
private readonly PathProvider $pathProvider,
private readonly Clock $clock,
private readonly Storage $storage,
private readonly string $mediaPrefix = '/media'
) {
}
public function process(Request $request, callable $next): HttpResponse
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
{
$path = $request->getPath();
$request = $context->request;
$path = $request->path;
// Prüfen ob es sich um eine Media-Anfrage handelt
if (str_starts_with($path, $this->mediaPrefix)) {
$filePath = substr($path, strlen($this->mediaPrefix));
return $this->serveStaticFile($filePath);
$response = $this->serveStaticFile($filePath);
return new MiddlewareContext($request, $response);
}
return $next($request);
return $next($context);
}
private function serveStaticFile(string $filePath): HttpResponse
@@ -62,7 +76,7 @@ class ServeStaticFilesMiddleware implements Middleware
// Dateiendung prüfen
$extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
if (!in_array($extension, $this->allowedExtensions)) {
if (! in_array($extension, $this->allowedExtensions)) {
return $this->notFound();
}
@@ -70,32 +84,33 @@ class ServeStaticFilesMiddleware implements Middleware
$fullPath = $this->pathProvider->resolvePath('storage' . $filePath);
// Prüfen ob Datei existiert
if (!file_exists($fullPath) || !is_file($fullPath)) {
if (! $this->storage->exists($fullPath)) {
return $this->notFound();
}
// MIME-Typ ermitteln
$mimeType = $this->mimeTypes[$extension] ?? 'application/octet-stream';
// Datei auslesen
$content = file_get_contents($fullPath);
// Storage verwenden
$content = $this->storage->get($fullPath);
// Cache-Control Header basierend auf Dateityp setzen
$cacheControl = $this->getCacheControlHeader($extension);
// ETag für Caching generieren
$etag = '"' . md5_file($fullPath) . '"';
$etag = '"' . md5($content) . '"';
// Last-Modified Header
$lastModified = gmdate('D, d M Y H:i:s', filemtime($fullPath)) . ' GMT';
// Last-Modified Header mit Clock
$lastModified = $this->clock->now()
->format('D, d M Y H:i:s') . ' GMT';
// Headers zusammenstellen
$headers = new Headers([
'Content-Type' => $mimeType,
'Content-Length' => filesize($fullPath),
'Content-Length' => (string) strlen($content),
'Cache-Control' => $cacheControl,
'ETag' => $etag,
'Last-Modified' => $lastModified
'Last-Modified' => $lastModified,
]);
return new HttpResponse(
@@ -108,7 +123,7 @@ class ServeStaticFilesMiddleware implements Middleware
private function sanitizePath(string $path): string
{
// Doppelte Slashes entfernen
$path = preg_replace('#/+#', '/', $path);
$path = preg_replace('#/+#', '/', $path) ?? $path;
// Führende/nachfolgende Slashes entfernen
$path = trim($path, '/');

View File

@@ -0,0 +1,247 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Middlewares;
use App\Framework\Config\WafConfig;
use App\Framework\Http\HttpMiddleware;
use App\Framework\Http\HttpResponse;
use App\Framework\Http\MiddlewareContext;
use App\Framework\Http\MiddlewarePriority;
use App\Framework\Http\MiddlewarePriorityAttribute;
use App\Framework\Http\Next;
use App\Framework\Http\RequestStateManager;
use App\Framework\Http\Responses\JsonResponse;
use App\Framework\Logging\Logger;
use App\Framework\Waf\LayerResult;
use App\Framework\Waf\WafEngine;
/**
* WAF (Web Application Firewall) Middleware
*
* Integrates the WAF engine into the HTTP request pipeline for security analysis
* and threat detection. Blocks or flags requests based on WAF layer results.
*/
#[MiddlewarePriorityAttribute(MiddlewarePriority::SECURITY)]
final readonly class WafMiddleware implements HttpMiddleware
{
public function __construct(
private WafEngine $wafEngine,
private Logger $logger,
private WafConfig $config
) {
// Ensure security layers are registered
$this->initializeSecurityLayers();
}
/**
* Initialize security layers if not already registered
*/
private function initializeSecurityLayers(): void
{
// Check if layers are already registered (avoid double registration)
$healthStatus = $this->wafEngine->getHealthStatus();
if ($healthStatus['total_layers'] > 0) {
return; // Already initialized
}
try {
// Register security layers directly
$this->wafEngine->registerLayer(new \App\Framework\Waf\Layers\SqlInjectionLayer());
$this->wafEngine->registerLayer(new \App\Framework\Waf\Layers\CommandInjectionLayer());
$this->wafEngine->registerLayer(new \App\Framework\Waf\Layers\PathTraversalLayer());
$this->wafEngine->registerLayer(new \App\Framework\Waf\Layers\XssLayer());
$this->wafEngine->registerLayer(new \App\Framework\Waf\Layers\SuspiciousUserAgentLayer());
$this->logger->info('WAF security layers initialized', [
'layers_count' => 5,
'health_status' => $this->wafEngine->getHealthStatus(),
]);
} catch (\Throwable $e) {
$this->logger->error('Failed to initialize WAF security layers', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
}
}
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
{
if (! $this->config->enabled) {
$this->logger->debug('WAF disabled, skipping analysis');
return $next($context);
}
try {
$request = $context->request;
// Debug log request details
$this->logger->debug('WAF analyzing request', [
'path' => $request->path ?? '/',
'method' => $request->method->value ?? 'UNKNOWN',
'query_params' => $request->queryParams,
'post_data' => $request->parsedBody->data ?? null,
'user_agent' => $request->headers->get('User-Agent', ''),
'client_ip' => $request->server->getClientIp()?->value ?? 'unknown',
'waf_config' => [
'enabled' => $this->config->enabled,
'blocking_mode' => $this->config->blockingMode,
'enabled_layers' => $this->config->enabledLayers,
],
]);
// Analyze request with WAF engine
$wafResult = $this->wafEngine->analyze($request);
// Debug log analysis result
$this->logger->debug('WAF analysis complete', [
'result_status' => $wafResult->getStatus()->value ?? 'unknown',
'result_action' => $wafResult->getAction(),
'layer_name' => $wafResult->getLayerName(),
'message' => $wafResult->getMessage(),
'has_detections' => $wafResult->hasDetections(),
'detections_count' => $wafResult->hasDetections() ? count($wafResult->getDetections()->getAll()) : 0,
]);
// Handle based on result action
switch ($wafResult->getAction()) {
case LayerResult::ACTION_BLOCK:
$this->logger->info('WAF blocking request', ['reason' => $wafResult->getMessage()]);
return $this->handleBlocked($context, $wafResult);
case LayerResult::ACTION_SUSPICIOUS:
$this->logger->info('WAF flagging suspicious request', ['reason' => $wafResult->getMessage()]);
return $this->handleSuspicious($context, $wafResult, $next);
case LayerResult::ACTION_PASS:
default:
$this->logger->debug('WAF allowing request', ['reason' => $wafResult->getMessage()]);
// Continue to next middleware
return $next($context);
}
} catch (\Throwable $e) {
// Log WAF error but don't block request on WAF failure
$this->logger->error('WAF middleware error', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
'request_path' => $context->request->path ?? '/',
'client_ip' => $context->request->server->getClientIp()?->value ?? 'unknown',
]);
// Continue processing on WAF error
return $next($context);
}
}
/**
* Handle blocked requests
*/
private function handleBlocked(MiddlewareContext $context, LayerResult $wafResult): MiddlewareContext
{
$request = $context->request;
$clientIp = $request->server->getClientIp()?->value ?? 'unknown';
// Log security event
$this->logger->warning('WAF blocked request', [
'reason' => $wafResult->getMessage(),
'client_ip' => $clientIp,
'path' => $request->path ?? '/',
'user_agent' => $request->headers->get('User-Agent', ''),
'detections' => $this->formatDetections($wafResult),
'layer' => $wafResult->getLayerName(),
]);
if ($this->config->blockingMode) {
// Return 403 Forbidden response
$response = new JsonResponse([
'error' => 'Request blocked by security policy',
'code' => 'WAF_BLOCKED',
'request_id' => $request->id->value(),
], 403);
} else {
// Log-only mode - continue processing but log the threat
$this->logger->warning('WAF would block request (log-only mode)', [
'reason' => $wafResult->getMessage(),
'client_ip' => $clientIp,
'path' => $request->path ?? '/',
]);
$response = new JsonResponse([
'warning' => 'Request flagged by security policy',
'code' => 'WAF_FLAGGED',
], 200);
}
return $context->withResponse($response);
}
/**
* Handle suspicious requests
*/
private function handleSuspicious(MiddlewareContext $context, LayerResult $wafResult, Next $next): MiddlewareContext
{
$request = $context->request;
$clientIp = $request->server->getClientIp()?->value ?? 'unknown';
// Log suspicious activity
$this->logger->info('WAF flagged suspicious request', [
'reason' => $wafResult->getMessage(),
'client_ip' => $clientIp,
'path' => $request->path ?? '/',
'user_agent' => $request->headers->get('User-Agent', ''),
'detections' => $this->formatDetections($wafResult),
'layer' => $wafResult->getLayerName(),
]);
// Continue to next middleware
$resultContext = $next($context);
// Add WAF detection info to response headers for monitoring
if ($resultContext->hasResponse()) {
$response = $resultContext->response;
$updatedHeaders = $response->headers
->with('X-Waf-Status', 'suspicious')
->with('X-Waf-Layer', $wafResult->getLayerName());
$updatedResponse = new HttpResponse(
$response->body,
$response->statusCode,
$updatedHeaders
);
return $resultContext->withResponse($updatedResponse);
}
return $resultContext;
}
/**
* Format detection information for logging
*/
private function formatDetections(LayerResult $wafResult): array
{
if (! $wafResult->hasDetections()) {
return [];
}
$detections = [];
foreach ($wafResult->getDetections()->getAll() as $detection) {
$detections[] = [
'category' => $detection->category->value,
'severity' => $detection->severity->value,
'message' => $detection->message,
'confidence' => $detection->confidence,
'evidence' => $detection->evidence,
];
}
return $detections;
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
enum MimeType: string
@@ -10,6 +12,8 @@ enum MimeType: string
case TEXT_CSS = 'text/css';
case TEXT_CSV = 'text/csv';
case TEXT_JAVASCRIPT = 'text/javascript';
case TEXT_XML = 'text/xml';
case TEXT_MARKDOWN = 'text/markdown';
// Application
case APPLICATION_JSON = 'application/json';
@@ -18,6 +22,17 @@ enum MimeType: string
case APPLICATION_OCTET_STREAM = 'application/octet-stream';
case APPLICATION_PDF = 'application/pdf';
case APPLICATION_ZIP = 'application/zip';
case APPLICATION_GZIP = 'application/gzip';
case APPLICATION_RAR = 'application/x-rar-compressed';
case APPLICATION_7Z = 'application/x-7z-compressed';
// Microsoft Office
case APPLICATION_MSWORD = 'application/msword';
case APPLICATION_MSWORD_X = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
case APPLICATION_EXCEL = 'application/vnd.ms-excel';
case APPLICATION_EXCEL_X = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
case APPLICATION_POWERPOINT = 'application/vnd.ms-powerpoint';
case APPLICATION_POWERPOINT_X = 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
// Images
case IMAGE_JPEG = 'image/jpeg';
@@ -25,17 +40,97 @@ enum MimeType: string
case IMAGE_GIF = 'image/gif';
case IMAGE_SVG = 'image/svg+xml';
case IMAGE_WEBP = 'image/webp';
case IMAGE_BMP = 'image/bmp';
case IMAGE_TIFF = 'image/tiff';
case IMAGE_ICO = 'image/x-icon';
// Audio
case AUDIO_MPEG = 'audio/mpeg';
case AUDIO_WAV = 'audio/wav';
case AUDIO_OGG = 'audio/ogg';
case AUDIO_WEBM = 'audio/webm';
case AUDIO_AAC = 'audio/aac';
case AUDIO_FLAC = 'audio/flac';
// Video
case VIDEO_MP4 = 'video/mp4';
case VIDEO_WEBM = 'video/webm';
case VIDEO_OGG = 'video/ogg';
case VIDEO_AVI = 'video/x-msvideo';
case VIDEO_QUICKTIME = 'video/quicktime';
// Multipart
case MULTIPART_FORM_DATA = 'multipart/form-data';
// Fonts
case FONT_WOFF = 'font/woff';
case FONT_WOFF2 = 'font/woff2';
case FONT_TTF = 'font/ttf';
case FONT_OTF = 'font/otf';
// Convenience methods using analyzer classes
public function isImage(): bool
{
return MimeTypeAnalyzer::isImage($this);
}
public function isDocument(): bool
{
return MimeTypeAnalyzer::isDocument($this);
}
public function isText(): bool
{
return MimeTypeAnalyzer::isText($this);
}
public function isAudio(): bool
{
return MimeTypeAnalyzer::isAudio($this);
}
public function isVideo(): bool
{
return MimeTypeAnalyzer::isVideo($this);
}
public function isCompressed(): bool
{
return MimeTypeAnalyzer::isCompressed($this);
}
public function isStreamable(): bool
{
return MimeTypeAnalyzer::isStreamable($this);
}
public function isBinary(): bool
{
return MimeTypeAnalyzer::isBinary($this);
}
public function getExtensions(): array
{
return MimeTypeResolver::getExtensions($this);
}
public function getPreferredExtension(): ?string
{
return MimeTypeResolver::getPreferredExtension($this);
}
public static function fromExtension(string $extension): ?self
{
return MimeTypeResolver::fromExtension($extension);
}
public static function fromFilePath(string $filePath): ?self
{
return MimeTypeResolver::fromFilePath($filePath);
}
public static function fromString(string $mimeTypeString): ?self
{
return MimeTypeResolver::fromString($mimeTypeString);
}
}

View File

@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
final class MimeTypeAnalyzer
{
public static function isImage(MimeType $mimeType): bool
{
return match ($mimeType) {
MimeType::IMAGE_JPEG,
MimeType::IMAGE_PNG,
MimeType::IMAGE_GIF,
MimeType::IMAGE_SVG,
MimeType::IMAGE_WEBP,
MimeType::IMAGE_BMP,
MimeType::IMAGE_TIFF,
MimeType::IMAGE_ICO => true,
default => false,
};
}
public static function isDocument(MimeType $mimeType): bool
{
return match ($mimeType) {
MimeType::APPLICATION_PDF,
MimeType::APPLICATION_MSWORD,
MimeType::APPLICATION_MSWORD_X,
MimeType::APPLICATION_EXCEL,
MimeType::APPLICATION_EXCEL_X,
MimeType::APPLICATION_POWERPOINT,
MimeType::APPLICATION_POWERPOINT_X => true,
default => false,
};
}
public static function isText(MimeType $mimeType): bool
{
return match ($mimeType) {
MimeType::TEXT_PLAIN,
MimeType::TEXT_HTML,
MimeType::TEXT_CSS,
MimeType::TEXT_CSV,
MimeType::TEXT_JAVASCRIPT,
MimeType::TEXT_XML,
MimeType::TEXT_MARKDOWN => true,
default => false,
};
}
public static function isAudio(MimeType $mimeType): bool
{
return match ($mimeType) {
MimeType::AUDIO_MPEG,
MimeType::AUDIO_WAV,
MimeType::AUDIO_OGG,
MimeType::AUDIO_WEBM,
MimeType::AUDIO_AAC,
MimeType::AUDIO_FLAC => true,
default => false,
};
}
public static function isVideo(MimeType $mimeType): bool
{
return match ($mimeType) {
MimeType::VIDEO_MP4,
MimeType::VIDEO_WEBM,
MimeType::VIDEO_OGG,
MimeType::VIDEO_AVI,
MimeType::VIDEO_QUICKTIME => true,
default => false,
};
}
public static function isCompressed(MimeType $mimeType): bool
{
return match ($mimeType) {
MimeType::APPLICATION_ZIP,
MimeType::APPLICATION_GZIP,
MimeType::APPLICATION_RAR,
MimeType::APPLICATION_7Z => true,
default => false,
};
}
public static function isStreamable(MimeType $mimeType): bool
{
return self::isAudio($mimeType) || self::isVideo($mimeType);
}
public static function isBinary(MimeType $mimeType): bool
{
return ! self::isText($mimeType);
}
}

View File

@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
final class MimeTypeResolver
{
private const EXTENSION_MAP = [
// Text
'txt' => MimeType::TEXT_PLAIN,
'html' => MimeType::TEXT_HTML,
'htm' => MimeType::TEXT_HTML,
'css' => MimeType::TEXT_CSS,
'csv' => MimeType::TEXT_CSV,
'js' => MimeType::TEXT_JAVASCRIPT,
'xml' => MimeType::TEXT_XML,
'md' => MimeType::TEXT_MARKDOWN,
'markdown' => MimeType::TEXT_MARKDOWN,
// Application
'json' => MimeType::APPLICATION_JSON,
'pdf' => MimeType::APPLICATION_PDF,
'zip' => MimeType::APPLICATION_ZIP,
'gz' => MimeType::APPLICATION_GZIP,
'gzip' => MimeType::APPLICATION_GZIP,
'rar' => MimeType::APPLICATION_RAR,
'7z' => MimeType::APPLICATION_7Z,
'doc' => MimeType::APPLICATION_MSWORD,
'docx' => MimeType::APPLICATION_MSWORD_X,
'xls' => MimeType::APPLICATION_EXCEL,
'xlsx' => MimeType::APPLICATION_EXCEL_X,
'ppt' => MimeType::APPLICATION_POWERPOINT,
'pptx' => MimeType::APPLICATION_POWERPOINT_X,
// Images
'jpg' => MimeType::IMAGE_JPEG,
'jpeg' => MimeType::IMAGE_JPEG,
'png' => MimeType::IMAGE_PNG,
'gif' => MimeType::IMAGE_GIF,
'svg' => MimeType::IMAGE_SVG,
'webp' => MimeType::IMAGE_WEBP,
'bmp' => MimeType::IMAGE_BMP,
'tiff' => MimeType::IMAGE_TIFF,
'tif' => MimeType::IMAGE_TIFF,
'ico' => MimeType::IMAGE_ICO,
// Audio
'mp3' => MimeType::AUDIO_MPEG,
'wav' => MimeType::AUDIO_WAV,
'ogg' => MimeType::AUDIO_OGG,
'aac' => MimeType::AUDIO_AAC,
'flac' => MimeType::AUDIO_FLAC,
// Video
'mp4' => MimeType::VIDEO_MP4,
'webm' => MimeType::VIDEO_WEBM,
'avi' => MimeType::VIDEO_AVI,
'mov' => MimeType::VIDEO_QUICKTIME,
// Fonts
'woff' => MimeType::FONT_WOFF,
'woff2' => MimeType::FONT_WOFF2,
'ttf' => MimeType::FONT_TTF,
'otf' => MimeType::FONT_OTF,
];
private const MIME_TYPE_EXTENSIONS = [
MimeType::TEXT_PLAIN->value => ['txt'],
MimeType::TEXT_HTML->value => ['html', 'htm'],
MimeType::TEXT_CSS->value => ['css'],
MimeType::TEXT_CSV->value => ['csv'],
MimeType::TEXT_JAVASCRIPT->value => ['js'],
MimeType::TEXT_XML->value => ['xml'],
MimeType::TEXT_MARKDOWN->value => ['md', 'markdown'],
MimeType::APPLICATION_JSON->value => ['json'],
MimeType::APPLICATION_PDF->value => ['pdf'],
MimeType::APPLICATION_ZIP->value => ['zip'],
MimeType::APPLICATION_GZIP->value => ['gz', 'gzip'],
MimeType::APPLICATION_RAR->value => ['rar'],
MimeType::APPLICATION_7Z->value => ['7z'],
MimeType::APPLICATION_MSWORD->value => ['doc'],
MimeType::APPLICATION_MSWORD_X->value => ['docx'],
MimeType::APPLICATION_EXCEL->value => ['xls'],
MimeType::APPLICATION_EXCEL_X->value => ['xlsx'],
MimeType::APPLICATION_POWERPOINT->value => ['ppt'],
MimeType::APPLICATION_POWERPOINT_X->value => ['pptx'],
MimeType::IMAGE_JPEG->value => ['jpg', 'jpeg'],
MimeType::IMAGE_PNG->value => ['png'],
MimeType::IMAGE_GIF->value => ['gif'],
MimeType::IMAGE_SVG->value => ['svg'],
MimeType::IMAGE_WEBP->value => ['webp'],
MimeType::IMAGE_BMP->value => ['bmp'],
MimeType::IMAGE_TIFF->value => ['tiff', 'tif'],
MimeType::IMAGE_ICO->value => ['ico'],
MimeType::AUDIO_MPEG->value => ['mp3'],
MimeType::AUDIO_WAV->value => ['wav'],
MimeType::AUDIO_OGG->value => ['ogg'],
MimeType::AUDIO_AAC->value => ['aac'],
MimeType::AUDIO_FLAC->value => ['flac'],
MimeType::VIDEO_MP4->value => ['mp4'],
MimeType::VIDEO_WEBM->value => ['webm'],
MimeType::VIDEO_AVI->value => ['avi'],
MimeType::VIDEO_QUICKTIME->value => ['mov'],
MimeType::FONT_WOFF->value => ['woff'],
MimeType::FONT_WOFF2->value => ['woff2'],
MimeType::FONT_TTF->value => ['ttf'],
MimeType::FONT_OTF->value => ['otf'],
];
public static function fromExtension(string $extension): ?MimeType
{
$extension = strtolower(ltrim($extension, '.'));
return self::EXTENSION_MAP[$extension] ?? null;
}
public static function fromFilePath(string $filePath): ?MimeType
{
$extension = pathinfo($filePath, PATHINFO_EXTENSION);
return self::fromExtension($extension);
}
public static function getExtensions(MimeType $mimeType): array
{
return self::MIME_TYPE_EXTENSIONS[$mimeType->value] ?? [];
}
public static function getPreferredExtension(MimeType $mimeType): ?string
{
$extensions = self::getExtensions($mimeType);
return $extensions[0] ?? null;
}
public static function fromString(string $mimeTypeString): ?MimeType
{
foreach (MimeType::cases() as $case) {
if ($case->value === $mimeTypeString) {
return $case;
}
}
return null;
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
/**
@@ -23,15 +25,19 @@ final readonly class NamespacedState
public function __construct(
private RequestStateManager $stateManager,
private string $namespace
){}
public function set(string $key, mixed $value): void
{
$this->stateManager->set("{$this->namespace}.{$key}", $value);
) {
}
public function get(string $key, mixed $default = null): mixed
public function set(StateKey|string $key, mixed $value): void
{
return $this->stateManager->get("{$this->namespace}.{$key}", $default);
$keyString = $key instanceof StateKey ? $key->value : $key;
$this->stateManager->set("{$this->namespace}.{$keyString}", $value);
}
public function get(StateKey|string $key, mixed $default = null): mixed
{
$keyString = $key instanceof StateKey ? $key->value : $key;
return $this->stateManager->get("{$this->namespace}.{$keyString}", $default);
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
interface Next
{
/**
* Handles the middleware context and returns the updated context.
*
* This interface provides type safety for the next handler in the
* middleware chain, replacing the generic callable with a typed interface.
* Using __invoke makes the interface callable like the original callable.
*
* @param MiddlewareContext $context The middleware context to process
* @return MiddlewareContext The updated context after processing
*/
public function __invoke(MiddlewareContext $context): MiddlewareContext;
}

View File

@@ -0,0 +1,308 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Parser;
use App\Framework\Http\Cookies\Cookie;
use App\Framework\Http\Cookies\Cookies;
use App\Framework\Http\Parser\Exception\ParserSecurityException;
/**
* Parser for HTTP Cookie headers with security validation
* Parses both Cookie request headers and Set-Cookie response headers
*/
final readonly class CookieParser
{
public function __construct(
private ParserCache $cache,
private ParserConfig $config = new ParserConfig()
) {
}
/**
* Parse a Cookie header string into individual cookies
* Format: "name1=value1; name2=value2; name3=value3"
*
* @param string $cookieHeader The raw Cookie header value
* @return array<string, string> Associative array of cookie names to values
* @throws ParserSecurityException
*/
public function parseCookieHeader(string $cookieHeader): array
{
if ($cookieHeader === '') {
return [];
}
// Try cache first
$cached = $this->cache->getCookies($cookieHeader);
if ($cached !== null) {
return $cached;
}
$cookies = [];
$pairs = explode(';', $cookieHeader);
// Security validation: Check cookie count
if (count($pairs) > $this->config->maxCookieCount) {
throw ParserSecurityException::cookieCountExceeded(
count($pairs),
$this->config->maxCookieCount
);
}
foreach ($pairs as $pair) {
$pair = trim($pair);
if ($pair === '') {
continue;
}
$parts = explode('=', $pair, 2);
if (count($parts) !== 2) {
// Invalid cookie pair, skip
continue;
}
$name = trim($parts[0]);
$value = trim($parts[1]);
if ($name !== '') {
// Security validation: Check name length
if (strlen($name) > $this->config->maxCookieNameLength) {
throw ParserSecurityException::cookieNameTooLong(
$name,
strlen($name),
$this->config->maxCookieNameLength
);
}
// Security validation: Check value length
if (strlen($value) > $this->config->maxCookieValueLength) {
throw ParserSecurityException::cookieValueTooLong(
$name,
strlen($value),
$this->config->maxCookieValueLength
);
}
// Security validation: Check for malicious content
if ($this->config->scanForMaliciousContent) {
$this->validateCookieContent($name, $value);
}
// Decode cookie value (cookies use URL encoding)
$cookies[$name] = urldecode($value);
}
}
// Cache the result before returning
$this->cache->setCookies($cookieHeader, $cookies);
return $cookies;
}
/**
* Parse Cookie header into Cookies value object
*
* @throws ParserSecurityException
*/
public function parseToCookies(string $cookieHeader): Cookies
{
$parsedCookies = $this->parseCookieHeader($cookieHeader);
$cookieObjects = [];
foreach ($parsedCookies as $name => $value) {
$cookieObjects[] = new Cookie($name, $value);
}
return new Cookies(...$cookieObjects);
}
/**
* Parse a Set-Cookie header (for response parsing)
* Format: "name=value; Expires=...; Path=...; Domain=...; Secure; HttpOnly; SameSite=..."
*
* @param string $setCookieHeader The raw Set-Cookie header value
* @return array{
* name: string,
* value: string,
* expires?: string,
* max-age?: int,
* domain?: string,
* path?: string,
* secure?: bool,
* httponly?: bool,
* samesite?: string
* }
* @throws ParserSecurityException
*/
public function parseSetCookieHeader(string $setCookieHeader): array
{
$parts = explode(';', $setCookieHeader);
$cookie = [];
// First part is always name=value
$nameValue = array_shift($parts);
if ($nameValue === null) {
throw new \InvalidArgumentException('Invalid Set-Cookie header: missing name=value');
}
$nvParts = explode('=', trim($nameValue), 2);
if (count($nvParts) !== 2) {
throw new \InvalidArgumentException('Invalid Set-Cookie header: invalid name=value format');
}
$name = trim($nvParts[0]);
$value = trim($nvParts[1]);
// Security validation: Check name length
if (strlen($name) > $this->config->maxCookieNameLength) {
throw ParserSecurityException::cookieNameTooLong(
$name,
strlen($name),
$this->config->maxCookieNameLength
);
}
// Security validation: Check value length
if (strlen($value) > $this->config->maxCookieValueLength) {
throw ParserSecurityException::cookieValueTooLong(
$name,
strlen($value),
$this->config->maxCookieValueLength
);
}
// Security validation: Check for malicious content
if ($this->config->scanForMaliciousContent) {
$this->validateCookieContent($name, $value);
}
$cookie['name'] = $name;
$cookie['value'] = urldecode($value);
// Parse attributes
foreach ($parts as $part) {
$part = trim($part);
if ($part === '') {
continue;
}
$attrParts = explode('=', $part, 2);
$attrName = strtolower(trim($attrParts[0]));
$attrValue = isset($attrParts[1]) ? trim($attrParts[1]) : null;
switch ($attrName) {
case 'expires':
$cookie['expires'] = $attrValue;
break;
case 'max-age':
$cookie['max-age'] = (int)$attrValue;
break;
case 'domain':
$cookie['domain'] = $attrValue;
break;
case 'path':
$cookie['path'] = $attrValue;
break;
case 'secure':
$cookie['secure'] = true;
break;
case 'httponly':
$cookie['httponly'] = true;
break;
case 'samesite':
$cookie['samesite'] = $attrValue;
break;
}
}
return $cookie;
}
/**
* Parse multiple Set-Cookie headers
*
* @param string[] $setCookieHeaders Array of Set-Cookie header values
* @return array<array> Array of parsed cookie data
* @throws ParserSecurityException
*/
public function parseSetCookieHeaders(array $setCookieHeaders): array
{
// Security validation: Check cookie count
if (count($setCookieHeaders) > $this->config->maxCookieCount) {
throw ParserSecurityException::cookieCountExceeded(
count($setCookieHeaders),
$this->config->maxCookieCount
);
}
$cookies = [];
foreach ($setCookieHeaders as $header) {
try {
$cookies[] = $this->parseSetCookieHeader($header);
} catch (\InvalidArgumentException $e) {
// Skip invalid cookies
continue;
}
}
return $cookies;
}
/**
* Validate cookie content for malicious patterns
*
* @throws ParserSecurityException
*/
private function validateCookieContent(string $name, string $value): void
{
// Check for control characters first (more specific check)
if (preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', $name . $value)) {
throw ParserSecurityException::maliciousContentDetected(
"Control characters detected in cookie '{$name}'"
);
}
// Check for CRLF injection
if (preg_match('/\r|\n/', $name . $value)) {
throw ParserSecurityException::maliciousContentDetected(
"CRLF injection detected in cookie '{$name}'"
);
}
// Check for script injection patterns
$maliciousPatterns = [
'/<script[^>]*>/i',
'/javascript:/i',
'/vbscript:/i',
'/on\w+\s*=/i', // event handlers like onclick, onload
'/expression\s*\(/i', // CSS expression
'/url\s*\(/i', // CSS url()
'/import\s/i', // CSS @import
];
foreach ($maliciousPatterns as $pattern) {
if (preg_match($pattern, $name) || preg_match($pattern, $value)) {
throw ParserSecurityException::maliciousContentDetected(
"Suspicious content detected in cookie '{$name}'"
);
}
}
// Check for excessive URL encoding
if (substr_count($value, '%') > 10) {
throw ParserSecurityException::maliciousContentDetected(
"Excessive URL encoding detected in cookie '{$name}'"
);
}
}
}

View File

@@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Parser\Exception;
use App\Framework\Exception\FrameworkException;
/**
* Exception thrown when parser security limits are exceeded
*/
final class ParserSecurityException extends FrameworkException
{
public static function fileSizeExceeded(string $filename, int $actualSize, int $maxSize): self
{
return self::simple(
"File size exceeded for '{$filename}': {$actualSize} bytes > {$maxSize} bytes maximum"
);
}
public static function fileCountExceeded(int $actualCount, int $maxCount): self
{
return self::simple(
"File count exceeded: {$actualCount} files > {$maxCount} maximum"
);
}
public static function totalUploadSizeExceeded(int $actualSize, int $maxSize): self
{
return self::simple(
"Total upload size exceeded: {$actualSize} bytes > {$maxSize} bytes maximum"
);
}
public static function fileExtensionBlocked(string $filename, string $extension): self
{
return self::simple(
"File extension blocked for '{$filename}': '.{$extension}' is not allowed"
);
}
public static function fileExtensionNotAllowed(string $filename, string $extension): self
{
return self::simple(
"File extension not allowed for '{$filename}': '.{$extension}' is not in allowed list"
);
}
public static function formDataSizeExceeded(int $actualSize, int $maxSize): self
{
return self::simple(
"Form data size exceeded: {$actualSize} bytes > {$maxSize} bytes maximum"
);
}
public static function fieldCountExceeded(int $actualCount, int $maxCount): self
{
return self::simple(
"Field count exceeded: {$actualCount} fields > {$maxCount} maximum"
);
}
public static function fieldNameTooLong(string $fieldName, int $actualLength, int $maxLength): self
{
return self::simple(
"Field name too long '{$fieldName}': {$actualLength} characters > {$maxLength} maximum"
);
}
public static function fieldValueTooLong(string $fieldName, int $actualLength, int $maxLength): self
{
return self::simple(
"Field value too long for '{$fieldName}': {$actualLength} characters > {$maxLength} maximum"
);
}
public static function maxPartsExceeded(int $actualCount, int $maxCount): self
{
return self::simple(
"Maximum number of parts exceeded: {$actualCount} parts > {$maxCount} maximum"
);
}
public static function queryStringSizeExceeded(int $actualLength, int $maxLength): self
{
return self::simple(
"Query string size exceeded: {$actualLength} characters > {$maxLength} maximum"
);
}
public static function queryParameterCountExceeded(int $actualCount, int $maxCount): self
{
return self::simple(
"Query parameter count exceeded: {$actualCount} parameters > {$maxCount} maximum"
);
}
public static function headerCountExceeded(int $actualCount, int $maxCount): self
{
return self::simple(
"Header count exceeded: {$actualCount} headers > {$maxCount} maximum"
);
}
public static function headerSizeExceeded(int $actualSize, int $maxSize): self
{
return self::simple(
"Total header size exceeded: {$actualSize} bytes > {$maxSize} bytes maximum"
);
}
public static function cookieCountExceeded(int $actualCount, int $maxCount): self
{
return self::simple(
"Cookie count exceeded: {$actualCount} cookies > {$maxCount} maximum"
);
}
public static function cookieNameTooLong(string $name, int $actualLength, int $maxLength): self
{
return self::simple(
"Cookie name too long '{$name}': {$actualLength} characters > {$maxLength} maximum"
);
}
public static function cookieValueTooLong(string $name, int $actualLength, int $maxLength): self
{
return self::simple(
"Cookie value too long for '{$name}': {$actualLength} characters > {$maxLength} maximum"
);
}
public static function multipartPartsExceeded(int $actualCount, int $maxCount): self
{
return self::simple(
"Multipart parts exceeded: {$actualCount} parts > {$maxCount} maximum"
);
}
public static function requestBodySizeExceeded(int $actualSize, int $maxSize): self
{
return self::simple(
"Request body size exceeded: {$actualSize} bytes > {$maxSize} bytes maximum"
);
}
public static function maliciousContentDetected(string $reason): self
{
return self::simple("Malicious content detected: {$reason}");
}
public static function mimeTypeMismatch(string $filename, string $expectedType, string $actualType): self
{
return self::simple(
"MIME type mismatch for '{$filename}': expected '{$expectedType}', got '{$actualType}'"
);
}
public static function headerNameTooLong(string $name, int $actualLength, int $maxLength): self
{
return self::simple(
"Header name too long '{$name}': {$actualLength} characters > {$maxLength} maximum"
);
}
public static function headerValueTooLong(string $name, int $actualLength, int $maxLength): self
{
return self::simple(
"Header value too long for '{$name}': {$actualLength} characters > {$maxLength} maximum"
);
}
public static function boundaryTooLong(int $actualLength, int $maxLength): self
{
return self::simple(
"Multipart boundary too long: {$actualLength} characters > {$maxLength} maximum"
);
}
}

View File

@@ -0,0 +1,522 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Parser;
use App\Framework\Http\Parser\Exception\ParserSecurityException;
use App\Framework\Http\UploadedFile;
use App\Framework\Http\UploadedFiles;
use App\Framework\Http\UploadError;
/**
* Parser for file uploads in multipart/form-data requests with security validation
*/
final readonly class FileUploadParser
{
public function __construct(
private ParserConfig $config = new ParserConfig()
) {
}
/**
* Parse multipart body for file uploads
*
* @param string $body Raw request body
* @param string $boundary Multipart boundary
* @return UploadedFiles
* @throws ParserSecurityException
*/
public function parseMultipart(string $body, string $boundary): UploadedFiles
{
$files = [];
$totalUploadSize = 0;
$fileCount = 0;
// Security validation: Check boundary length
if (strlen($boundary) > $this->config->maxBoundaryLength) {
throw ParserSecurityException::boundaryTooLong(
strlen($boundary),
$this->config->maxBoundaryLength
);
}
// Split by boundary
$parts = explode("--$boundary", $body);
foreach ($parts as $part) {
// Skip empty parts and closing boundary
if (trim($part) === '' || trim($part) === '--') {
continue;
}
$file = $this->parseFilePart($part);
if ($file !== null) {
$fileCount++;
$fileSize = $file['upload']->size;
$totalUploadSize += $fileSize;
// Security validation: Check file count
if ($fileCount > $this->config->maxFileCount) {
throw ParserSecurityException::fileCountExceeded(
$fileCount,
$this->config->maxFileCount
);
}
// Security validation: Check individual file size
if ($fileSize > $this->config->maxFileSize->toBytes()) {
throw ParserSecurityException::fileSizeExceeded(
$file['upload']->name ?? 'unknown',
$fileSize,
$this->config->maxFileSize->toBytes()
);
}
// Security validation: Check total upload size
if ($totalUploadSize > $this->config->maxTotalUploadSize->toBytes()) {
throw ParserSecurityException::totalUploadSizeExceeded(
$totalUploadSize,
$this->config->maxTotalUploadSize->toBytes()
);
}
// Security validation: Check file extension
$this->validateFileExtension($file['upload']->name ?? '');
// Security validation: Check file content if enabled
if ($this->config->scanForMaliciousContent) {
$this->validateFileContent($file['upload']);
}
$files = $this->addFileToArray($files, $file['name'], $file['upload']);
}
}
return new UploadedFiles($files);
}
/**
* Parse a single multipart part that might be a file
*
* @param string $part The multipart part
* @return array{name: string, upload: UploadedFile}|null
*/
private function parseFilePart(string $part): ?array
{
// Split headers and content
$sections = explode("\r\n\r\n", $part, 2);
if (count($sections) !== 2) {
return null;
}
[$headerSection, $content] = $sections;
// Remove trailing CRLF from content
$content = rtrim($content, "\r\n");
// Parse headers
$headers = $this->parsePartHeaders($headerSection);
// Check Content-Disposition for file upload
$disposition = $headers['content-disposition'] ?? null;
if ($disposition === null) {
return null;
}
$dispParams = $this->parseContentDisposition($disposition);
// Must have name and filename to be a file upload
if (! isset($dispParams['name']) || ! isset($dispParams['filename'])) {
return null;
}
// Get content type
$contentType = $headers['content-type'] ?? 'application/octet-stream';
// Create temporary file
$tmpFile = $this->createTempFile($content);
// Create UploadedFile object using testing factory for parser
$uploadedFile = UploadedFile::createForTesting(
name: $dispParams['filename'],
type: $contentType,
size: strlen($content),
tmpName: $tmpFile,
error: UploadError::OK
);
return [
'name' => $dispParams['name'],
'upload' => $uploadedFile,
];
}
/**
* Parse headers from a multipart part
*
* @param string $headerSection Raw headers
* @return array<string, string> Normalized headers (lowercase keys)
*/
private function parsePartHeaders(string $headerSection): array
{
$headers = [];
$lines = explode("\r\n", $headerSection);
foreach ($lines as $line) {
if ($line === '' || strpos($line, ':') === false) {
continue;
}
[$name, $value] = explode(':', $line, 2);
$headers[strtolower(trim($name))] = trim($value);
}
return $headers;
}
/**
* Parse Content-Disposition header value
*
* @param string $disposition Content-Disposition header value
* @return array<string, string> Parameters
*/
private function parseContentDisposition(string $disposition): array
{
$params = [];
$parts = explode(';', $disposition);
// First part is disposition type (usually "form-data")
array_shift($parts);
foreach ($parts as $part) {
$part = trim($part);
if (strpos($part, '=') === false) {
continue;
}
[$key, $value] = explode('=', $part, 2);
$key = trim($key);
$value = trim($value, ' "');
// Decode filename if needed (RFC 2231)
if ($key === 'filename*') {
// Handle extended filename parameter
$value = $this->decodeExtendedFilename($value);
$params['filename'] = $value;
} else {
$params[$key] = $value;
}
}
return $params;
}
/**
* Decode RFC 2231 extended filename parameter
* Format: charset'language'encoded-filename
*
* @param string $value Extended parameter value
* @return string Decoded filename
*/
private function decodeExtendedFilename(string $value): string
{
$parts = explode("'", $value, 3);
if (count($parts) !== 3) {
// Invalid format, return as-is
return $value;
}
[$charset, $language, $encodedFilename] = $parts;
// URL decode the filename
$filename = rawurldecode($encodedFilename);
// Convert charset if needed
if ($charset !== '' && $charset !== 'UTF-8') {
$filename = mb_convert_encoding($filename, 'UTF-8', $charset);
}
return $filename;
}
/**
* Create temporary file with content
*
* @param string $content File content
* @return string Temporary file path
*/
private function createTempFile(string $content): string
{
$tmpFile = tempnam(sys_get_temp_dir(), 'upload_');
if ($tmpFile === false) {
throw new \RuntimeException('Failed to create temporary file');
}
if (file_put_contents($tmpFile, $content) === false) {
unlink($tmpFile);
throw new \RuntimeException('Failed to write temporary file');
}
// Register cleanup on script end
register_shutdown_function(function () use ($tmpFile) {
if (file_exists($tmpFile)) {
unlink($tmpFile);
}
});
return $tmpFile;
}
/**
* Add file to array, handling array notation
*
* @param array<string, mixed> $files Files array
* @param string $name Field name (may include [] notation)
* @param UploadedFile $file The uploaded file
* @return array<string, mixed> Updated files array
*/
private function addFileToArray(array $files, string $name, UploadedFile $file): array
{
// Check for array notation
if (preg_match('/^([^\[]+)(\[.*])$/', $name, $matches)) {
$baseKey = $matches[1];
$arrayPart = $matches[2];
if (! isset($files[$baseKey])) {
$files[$baseKey] = [];
}
// Simple array notation: field[]
if ($arrayPart === '[]') {
if (! is_array($files[$baseKey])) {
$files[$baseKey] = [$files[$baseKey]];
}
$files[$baseKey][] = $file;
} else {
// Complex array notation: field[key] or field[key1][key2]
// Parse the keys
preg_match_all('/\[([^]]*)]/', $arrayPart, $keyMatches);
$keys = $keyMatches[1];
$files[$baseKey] = $this->setNestedValue($files[$baseKey], $keys, $file);
}
} else {
// Simple field
$files[$name] = $file;
}
return $files;
}
/**
* Set value in nested array structure (immutable)
*
* @param mixed $target Target structure
* @param array<string> $keys Array of keys to traverse
* @param UploadedFile $file File to set
* @return mixed Updated structure
*/
private function setNestedValue(mixed $target, array $keys, UploadedFile $file): mixed
{
if (empty($keys)) {
return $file;
}
$key = array_shift($keys);
if (! is_array($target)) {
$target = [];
}
if ($key === '') {
// Empty key means array append
$index = count($target);
$target[$index] = $this->setNestedValue($target[$index] ?? null, $keys, $file);
} else {
$target[$key] = $this->setNestedValue($target[$key] ?? null, $keys, $file);
}
return $target;
}
/**
* Validate file extension against allowed/blocked lists
*
* @throws ParserSecurityException
*/
private function validateFileExtension(string $filename): void
{
if (! $this->config->validateFileExtensions || $filename === '') {
return;
}
$extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
if ($extension === '') {
return;
}
// Check blocked extensions first (takes precedence)
if (! empty($this->config->blockedFileExtensions)) {
if (in_array($extension, $this->config->blockedFileExtensions, true)) {
throw ParserSecurityException::fileExtensionBlocked($filename, $extension);
}
}
// Check allowed extensions if list is provided
if (! empty($this->config->allowedFileExtensions)) {
if (! in_array($extension, $this->config->allowedFileExtensions, true)) {
throw ParserSecurityException::fileExtensionNotAllowed($filename, $extension);
}
}
}
/**
* Validate file content for malicious patterns
*
* @throws ParserSecurityException
*/
private function validateFileContent(UploadedFile $file): void
{
$filename = $file->name ?? 'unknown';
$tmpFile = $file->tmpName;
if (! file_exists($tmpFile)) {
return;
}
// Read first few KB of the file for content analysis
$content = file_get_contents($tmpFile, false, null, 0, 8192);
if ($content === false) {
return;
}
// Check for embedded PHP code first (more specific)
if (preg_match('/<\?(?:php|=)/i', $content)) {
throw ParserSecurityException::maliciousContentDetected(
"PHP code detected in uploaded file '{$filename}'"
);
}
// Check for executable file signatures
$executableSignatures = [
"\x4D\x5A", // PE executable (Windows)
"\x7F\x45\x4C\x46", // ELF executable (Linux)
"\xFE\xED\xFA\xCE", // Mach-O executable (macOS)
"\xFE\xED\xFA\xCF", // Mach-O executable (macOS)
"\xCA\xFE\xBA\xBE", // Java class file
"#!/bin/sh", // Shell script
"#!/bin/bash", // Bash script
];
foreach ($executableSignatures as $signature) {
if (str_starts_with($content, $signature) || strpos($content, $signature) !== false) {
throw ParserSecurityException::maliciousContentDetected(
"Executable content detected in file '{$filename}'"
);
}
}
// Check for suspicious script content
$scriptPatterns = [
'/\<script[^>]*\>/i', // Script tags
'/javascript:/i', // JavaScript URLs
'/vbscript:/i', // VBScript URLs
'/on\w+\s*=/i', // Event handlers
'/eval\s*\(/i', // JavaScript eval
'/exec\s*\(/i', // Command execution
'/system\s*\(/i', // System calls
'/shell_exec\s*\(/i', // Shell execution
];
foreach ($scriptPatterns as $pattern) {
if (preg_match($pattern, $content)) {
throw ParserSecurityException::maliciousContentDetected(
"Suspicious script content detected in file '{$filename}'"
);
}
}
// Check for MIME type mismatch if client provided type
$clientType = $file->type;
if ($clientType && $clientType !== 'application/octet-stream') {
$detectedType = $this->detectMimeType($content, $filename);
if ($detectedType && $detectedType !== $clientType) {
// Only throw for significant mismatches (not minor variations)
if (! $this->isMimeTypeCompatible($clientType, $detectedType)) {
throw ParserSecurityException::mimeTypeMismatch($filename, $clientType, $detectedType);
}
}
}
}
/**
* Detect MIME type from file content
*/
private function detectMimeType(string $content, string $filename): ?string
{
// Basic MIME type detection based on file signatures
$signatures = [
'image/jpeg' => ["\xFF\xD8\xFF"],
'image/png' => ["\x89\x50\x4E\x47\x0D\x0A\x1A\x0A"],
'image/gif' => ["GIF87a", "GIF89a"],
'image/webp' => ["RIFF"],
'application/pdf' => ["%PDF-"],
'application/zip' => ["PK\x03\x04", "PK\x05\x06", "PK\x07\x08"],
'text/plain' => [], // Default for text-like content
];
foreach ($signatures as $mimeType => $sigs) {
foreach ($sigs as $sig) {
if (str_starts_with($content, $sig)) {
return $mimeType;
}
}
}
// Check if content appears to be text
if (mb_check_encoding($content, 'UTF-8') && ! preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', $content)) {
return 'text/plain';
}
return 'application/octet-stream';
}
/**
* Check if MIME types are compatible (allowing for minor variations)
*/
private function isMimeTypeCompatible(string $clientType, string $detectedType): bool
{
if ($clientType === $detectedType) {
return true;
}
// Allow some common compatible types
$compatibleTypes = [
'text/plain' => ['text/csv', 'text/tab-separated-values', 'application/csv'],
'application/octet-stream' => ['*'], // Octet-stream can be anything
'image/jpeg' => ['image/jpg'],
'application/zip' => ['application/x-zip-compressed'],
];
$clientMain = explode('/', $clientType)[0] ?? '';
$detectedMain = explode('/', $detectedType)[0] ?? '';
// Allow same main type (e.g., image/jpeg vs image/jpg)
if ($clientMain === $detectedMain) {
return true;
}
// Check specific compatibility rules
foreach ($compatibleTypes as $baseType => $compatible) {
if ($clientType === $baseType) {
return in_array($detectedType, $compatible, true) || in_array('*', $compatible, true);
}
}
return false;
}
}

View File

@@ -0,0 +1,358 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Parser;
use App\Framework\Http\Parser\Exception\ParserSecurityException;
/**
* Parser for form data with security validation
* (application/x-www-form-urlencoded and multipart/form-data)
*/
final readonly class FormDataParser
{
public function __construct(
private ParserConfig $config = new ParserConfig(),
private QueryStringParser $queryStringParser = new QueryStringParser()
) {
}
/**
* Parse form data based on content type
*
* @param string $contentType The Content-Type header value
* @param string $body The raw request body
* @return array<string, mixed> Parsed form data
* @throws ParserSecurityException
*/
public function parse(string $contentType, string $body): array
{
if ($body === '') {
return [];
}
// Security validation: Check form data size
$bodySize = strlen($body);
if ($bodySize > $this->config->maxFormDataSize->toBytes()) {
throw ParserSecurityException::formDataSizeExceeded(
$bodySize,
$this->config->maxFormDataSize->toBytes()
);
}
// Parse content type to get media type and parameters
$ctParts = explode(';', $contentType);
$mediaType = strtolower(trim($ctParts[0]));
switch ($mediaType) {
case 'application/x-www-form-urlencoded':
return $this->parseUrlEncoded($body);
case 'multipart/form-data':
$boundary = $this->extractBoundary($contentType);
if ($boundary === null) {
throw new \RuntimeException('Missing boundary in multipart/form-data');
}
return $this->parseMultipart($body, $boundary);
default:
// Not form data
return [];
}
}
/**
* Parse URL-encoded form data
* Same format as query strings
*
* @param string $body The raw body
* @return array<string, mixed>
*/
private function parseUrlEncoded(string $body): array
{
return $this->queryStringParser->parse($body);
}
/**
* Parse multipart/form-data
*
* @param string $body The raw body
* @param string $boundary The multipart boundary
* @return array<string, mixed> Form fields (not files)
* @throws ParserSecurityException
*/
private function parseMultipart(string $body, string $boundary): array
{
$data = [];
// Security validation: Check boundary length
if (strlen($boundary) > $this->config->maxBoundaryLength) {
throw ParserSecurityException::boundaryTooLong(
strlen($boundary),
$this->config->maxBoundaryLength
);
}
// Split by boundary
$parts = explode("--$boundary", $body);
// Security validation: Check multipart parts count
$actualParts = array_filter($parts, fn ($part) => trim($part) !== '' && trim($part) !== '--');
if (count($actualParts) > $this->config->maxMultipartParts) {
throw ParserSecurityException::multipartPartsExceeded(
count($actualParts),
$this->config->maxMultipartParts
);
}
$fieldCount = 0;
foreach ($parts as $part) {
// Skip empty parts and closing boundary
if (trim($part) === '' || trim($part) === '--') {
continue;
}
// Split headers and content
$sections = explode("\r\n\r\n", $part, 2);
if (count($sections) !== 2) {
continue;
}
[$headers, $content] = $sections;
// Remove trailing CRLF from content
$content = rtrim($content, "\r\n");
// Parse Content-Disposition header
$disposition = $this->parseContentDisposition($headers);
if ($disposition === null || ! isset($disposition['name'])) {
continue;
}
$name = $disposition['name'];
// Check if this is a file upload (has filename parameter)
if (isset($disposition['filename'])) {
// Skip file uploads in form data parser
// These should be handled by FileUploadParser
continue;
}
// Security validation: Check field count
$fieldCount++;
if ($fieldCount > $this->config->maxFieldCount) {
throw ParserSecurityException::fieldCountExceeded(
$fieldCount,
$this->config->maxFieldCount
);
}
// Security validation: Check field name length
if (strlen($name) > $this->config->maxFieldNameLength) {
throw ParserSecurityException::fieldNameTooLong(
$name,
strlen($name),
$this->config->maxFieldNameLength
);
}
// Security validation: Check field value length
if (strlen($content) > $this->config->maxFieldValueLength) {
throw ParserSecurityException::fieldValueTooLong(
$name,
strlen($content),
$this->config->maxFieldValueLength
);
}
// Security validation: Check for malicious content
if ($this->config->scanForMaliciousContent) {
$this->validateFieldContent($name, $content);
}
// Parse field name for array notation
$data = $this->assignFieldValue($data, $name, $content);
}
return $data;
}
/**
* Extract boundary from Content-Type header
*
* @param string $contentType Full Content-Type header value
* @return string|null Boundary string or null if not found
*/
private function extractBoundary(string $contentType): ?string
{
if (preg_match('/boundary=([^;]+)/', $contentType, $matches)) {
$boundary = trim($matches[1], ' "');
return $boundary;
}
return null;
}
/**
* Parse Content-Disposition header
*
* @param string $headers The headers section of a multipart part
* @return array{name?: string, filename?: string}|null
*/
private function parseContentDisposition(string $headers): ?array
{
$lines = explode("\r\n", $headers);
foreach ($lines as $line) {
if (stripos($line, 'Content-Disposition:') === 0) {
$disposition = [];
$parts = explode(';', $line);
foreach ($parts as $part) {
$part = trim($part);
if (strpos($part, '=') !== false) {
[$key, $value] = explode('=', $part, 2);
$key = trim(strtolower($key));
$value = trim($value, ' "');
if ($key === 'name' || $key === 'filename') {
$disposition[$key] = $value;
}
}
}
return $disposition;
}
}
return null;
}
/**
* Assign field value, handling array notation
*
* @param array<string, mixed> $data Data array
* @param string $name Field name (may include [] notation)
* @param string $value Field value
* @return array<string, mixed> Updated data array
*/
private function assignFieldValue(array $data, string $name, string $value): array
{
// Check for array notation
if (preg_match('/^([^\[]+)(\[.*\])$/', $name, $matches)) {
$baseKey = $matches[1];
$arrayPart = $matches[2];
// Initialize array if needed
if (! isset($data[$baseKey])) {
$data[$baseKey] = [];
}
// Simple array notation field[]
if ($arrayPart === '[]') {
if (! is_array($data[$baseKey])) {
$data[$baseKey] = [$data[$baseKey]];
}
$data[$baseKey][] = $value;
} else {
// Complex array notation field[key] or field[key1][key2]
$data = $this->assignNestedValue($data, $name, $value);
}
} else {
// Simple field
$data[$name] = $value;
}
return $data;
}
/**
* Handle nested array notation
* Delegates to QueryStringParser for consistency
*/
private function assignNestedValue(array $data, string $name, string $value): array
{
// Create a temporary query string and parse it
$encoded = urlencode($name) . '=' . urlencode($value);
$parsed = $this->queryStringParser->parse($encoded);
// Merge with existing data
return array_merge_recursive($data, $parsed);
}
/**
* Validate field content for malicious patterns
*
* @throws ParserSecurityException
*/
private function validateFieldContent(string $name, string $value): void
{
// Check for control characters
if (preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', $name . $value)) {
throw ParserSecurityException::maliciousContentDetected(
"Control characters detected in form field '{$name}'"
);
}
// Check for script injection patterns
$maliciousPatterns = [
'/<script[^>]*>/i', // Script tags
'/javascript:/i', // JavaScript URLs
'/vbscript:/i', // VBScript URLs
'/on\w+\s*=/i', // Event handlers like onclick, onload
'/expression\s*\(/i', // CSS expression
'/eval\s*\(/i', // JavaScript eval
'/alert\s*\(/i', // JavaScript alert
'/<iframe[^>]*>/i', // Iframe injection
'/<object[^>]*>/i', // Object injection
'/<embed[^>]*>/i', // Embed injection
];
foreach ($maliciousPatterns as $pattern) {
if (preg_match($pattern, $value)) {
throw ParserSecurityException::maliciousContentDetected(
"Suspicious content detected in form field '{$name}'"
);
}
}
// Check for SQL injection patterns
$sqlPatterns = [
'/union\s+select/i',
'/select\s+.*\s+from/i',
'/insert\s+into/i',
'/delete\s+from/i',
'/drop\s+table/i',
'/truncate\s+table/i',
'/alter\s+table/i',
'/create\s+table/i',
];
foreach ($sqlPatterns as $pattern) {
if (preg_match($pattern, $value)) {
throw ParserSecurityException::maliciousContentDetected(
"SQL injection attempt detected in form field '{$name}'"
);
}
}
// Check for path traversal attempts
if (preg_match('/\.\.\/|\.\.\\\\/', $value)) {
throw ParserSecurityException::maliciousContentDetected(
"Path traversal attempt detected in form field '{$name}'"
);
}
// Check for excessive repetition (potential DoS)
if (preg_match('/(.)\1{100,}/', $value)) {
throw ParserSecurityException::maliciousContentDetected(
"Excessive character repetition detected in form field '{$name}'"
);
}
}
}

View File

@@ -0,0 +1,355 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Parser;
use App\Framework\Http\Headers;
use App\Framework\Http\Parser\Exception\ParserSecurityException;
/**
* Parser for HTTP headers with security validation
* Handles parsing from various sources (raw headers, $_SERVER array, etc.)
*/
final class HeaderParser
{
public function __construct(
private readonly ParserConfig $config = new ParserConfig()
) {
}
/**
* Parse headers from raw HTTP header string
*
* @param string $rawHeaders Raw headers separated by \r\n
* @return Headers
* @throws ParserSecurityException
*/
public function parseRawHeaders(string $rawHeaders): Headers
{
// Security validation: Check total header size
$headerSize = strlen($rawHeaders);
if ($headerSize > $this->config->maxTotalHeaderSize->toBytes()) {
throw ParserSecurityException::headerSizeExceeded(
$headerSize,
$this->config->maxTotalHeaderSize->toBytes()
);
}
$headers = [];
$lines = explode("\r\n", $rawHeaders);
// Security validation: Check header count
$headerLineCount = count(array_filter($lines, fn ($line) => str_contains($line, ':')));
if ($headerLineCount > $this->config->maxHeaderCount) {
throw ParserSecurityException::headerCountExceeded(
$headerLineCount,
$this->config->maxHeaderCount
);
}
foreach ($lines as $line) {
if ($line === '') {
// Empty line marks end of headers
break;
}
// Skip the request/status line
if (! str_contains($line, ':')) {
continue;
}
[$name, $value] = explode(':', $line, 2);
$name = trim($name);
$value = trim($value);
if ($name !== '') {
// Security validation: Check header name length
if (strlen($name) > $this->config->maxHeaderNameLength) {
throw ParserSecurityException::headerNameTooLong(
$name,
strlen($name),
$this->config->maxHeaderNameLength
);
}
// Security validation: Check header value length
if (strlen($value) > $this->config->maxHeaderValueLength) {
throw ParserSecurityException::headerValueTooLong(
$name,
strlen($value),
$this->config->maxHeaderValueLength
);
}
// Security validation: Check for malicious content
if ($this->config->scanForMaliciousContent) {
$this->validateHeaderContent($name, $value);
}
if (isset($headers[$name])) {
// Multiple headers with same name
if (! is_array($headers[$name])) {
$headers[$name] = [$headers[$name]];
}
$headers[$name][] = $value;
} else {
$headers[$name] = $value;
}
}
}
return new Headers($headers);
}
/**
* Parse headers from $_SERVER array
*
* @param array<string, mixed> $server Server array
* @return Headers
* @throws ParserSecurityException
*/
public function parseFromServerArray(array $server): Headers
{
$headers = [];
$totalHeaderSize = 0;
foreach ($server as $key => $value) {
// Convert scalar values to string
if (! is_string($value)) {
continue;
}
$headerName = null;
// Standard headers with HTTP_ prefix
if (str_starts_with($key, 'HTTP_')) {
$headerName = $this->normalizeHeaderName(substr($key, 5));
$headerValue = $value;
}
// Special headers without HTTP_ prefix
elseif (in_array($key, ['CONTENT_TYPE', 'CONTENT_LENGTH', 'CONTENT_MD5'])) {
$headerName = $this->normalizeHeaderName($key);
$headerValue = $value;
} else {
continue;
}
// Security validation: Check header name length
if (strlen($headerName) > $this->config->maxHeaderNameLength) {
throw ParserSecurityException::headerNameTooLong(
$headerName,
strlen($headerName),
$this->config->maxHeaderNameLength
);
}
// Security validation: Check header value length
if (strlen($headerValue) > $this->config->maxHeaderValueLength) {
throw ParserSecurityException::headerValueTooLong(
$headerName,
strlen($headerValue),
$this->config->maxHeaderValueLength
);
}
// Security validation: Check for malicious content
if ($this->config->scanForMaliciousContent) {
$this->validateHeaderContent($headerName, $headerValue);
}
$headers[$headerName] = $headerValue;
$totalHeaderSize += strlen($headerName) + strlen($headerValue) + 4; // name: value\r\n
}
// Add Authorization header if present in different formats
if (isset($server['PHP_AUTH_USER']) && isset($server['PHP_AUTH_PW'])) {
$authHeader = 'Basic ' . base64_encode($server['PHP_AUTH_USER'] . ':' . $server['PHP_AUTH_PW']);
// Security validation for generated auth header
if (strlen($authHeader) > $this->config->maxHeaderValueLength) {
throw ParserSecurityException::headerValueTooLong(
'Authorization',
strlen($authHeader),
$this->config->maxHeaderValueLength
);
}
$headers['Authorization'] = $authHeader;
$totalHeaderSize += strlen('Authorization') + strlen($authHeader) + 4;
} elseif (isset($server['PHP_AUTH_DIGEST'])) {
$authHeader = 'Digest ' . $server['PHP_AUTH_DIGEST'];
// Security validation for generated auth header
if (strlen($authHeader) > $this->config->maxHeaderValueLength) {
throw ParserSecurityException::headerValueTooLong(
'Authorization',
strlen($authHeader),
$this->config->maxHeaderValueLength
);
}
$headers['Authorization'] = $authHeader;
$totalHeaderSize += strlen('Authorization') + strlen($authHeader) + 4;
}
// Security validation: Check header count
if (count($headers) > $this->config->maxHeaderCount) {
throw ParserSecurityException::headerCountExceeded(
count($headers),
$this->config->maxHeaderCount
);
}
// Security validation: Check total header size
if ($totalHeaderSize > $this->config->maxTotalHeaderSize->toBytes()) {
throw ParserSecurityException::headerSizeExceeded(
$totalHeaderSize,
$this->config->maxTotalHeaderSize->toBytes()
);
}
return new Headers($headers);
}
/**
* Parse headers from getallheaders() output
*
* @param array<string, string> $headers Headers from getallheaders()
* @return Headers
*/
public function parseFromGetAllHeaders(array $headers): Headers
{
// getallheaders() returns headers in their original case
// No transformation needed, just pass to Headers constructor
return new Headers($headers);
}
/**
* Get all headers from current request
* Uses best available method
*
* @return Headers
*/
public function parseCurrentRequestHeaders(): Headers
{
// Prefer getallheaders() if available (Apache, FastCGI)
if (function_exists('getallheaders')) {
$headers = getallheaders();
if ($headers !== false) {
return $this->parseFromGetAllHeaders($headers);
}
}
// Fallback to $_SERVER parsing
return $this->parseFromServerArray($_SERVER);
}
/**
* Normalize header name from SERVER format
* HTTP_CONTENT_TYPE -> Content-Type
*
* @param string $serverKey Server key without HTTP_ prefix
* @return string Normalized header name
*/
private function normalizeHeaderName(string $serverKey): string
{
// Replace underscores with hyphens
$headerName = str_replace('_', '-', $serverKey);
// Convert to proper case (Content-Type, X-Forwarded-For, etc.)
return implode('-', array_map('ucfirst', explode('-', strtolower($headerName))));
}
/**
* Parse Content-Type header to extract media type and parameters
*
* @param string $contentType Raw Content-Type header value
* @return array{type: string, charset?: string, boundary?: string}
*/
public function parseContentType(string $contentType): array
{
$parts = explode(';', $contentType);
$result = ['type' => trim(array_shift($parts))];
foreach ($parts as $part) {
$part = trim($part);
if (str_contains($part, '=')) {
[$key, $value] = explode('=', $part, 2);
$key = trim(strtolower($key));
$value = trim($value, ' "');
if ($key === 'charset' || $key === 'boundary') {
$result[$key] = $value;
}
}
}
return $result;
}
/**
* Validate header content for malicious patterns
*
* @throws ParserSecurityException
*/
private function validateHeaderContent(string $name, string $value): void
{
// Check for control characters (HTTP headers should not contain control chars)
if (preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', $name . $value)) {
throw ParserSecurityException::maliciousContentDetected(
"Control characters detected in header '{$name}'"
);
}
// Check for CRLF injection (header splitting)
if (preg_match('/\r|\n/', $name . $value)) {
throw ParserSecurityException::maliciousContentDetected(
"CRLF injection detected in header '{$name}'"
);
}
// Check for suspicious patterns in header values
$maliciousPatterns = [
'/<script[^>]*>/i', // Script injection
'/javascript:/i', // JavaScript URL
'/vbscript:/i', // VBScript URL
'/on\w+\s*=/i', // Event handlers
'/expression\s*\(/i', // CSS expression
'/eval\s*\(/i', // JavaScript eval
'/alert\s*\(/i', // JavaScript alert
];
foreach ($maliciousPatterns as $pattern) {
if (preg_match($pattern, $value)) {
throw ParserSecurityException::maliciousContentDetected(
"Suspicious content detected in header '{$name}'"
);
}
}
// Check for suspicious header names
$suspiciousHeaderNames = [
'X-XSS-Protection', // Might be used to disable XSS protection
'Content-Security-Policy', // Might be used to weaken CSP
];
foreach ($suspiciousHeaderNames as $suspiciousName) {
if (stripos($name, $suspiciousName) !== false) {
// Check for potentially dangerous values
if (stripos($value, 'none') !== false || stripos($value, 'unsafe-inline') !== false) {
throw ParserSecurityException::maliciousContentDetected(
"Potentially dangerous security header value in '{$name}': '{$value}'"
);
}
}
}
// Check for excessively long base64 encoded values (potential data exfiltration)
if (preg_match('/^[A-Za-z0-9+\/]+=*$/', $value) && strlen($value) > 1000) {
throw ParserSecurityException::maliciousContentDetected(
"Suspicious base64 encoded value in header '{$name}'"
);
}
}
}

View File

@@ -0,0 +1,511 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Parser;
use App\Framework\Http\Headers;
use App\Framework\Http\HttpRequest;
use App\Framework\Http\Method;
use App\Framework\Http\Parser\Exception\ParserSecurityException;
use App\Framework\Http\Request;
use App\Framework\Http\RequestBody;
use App\Framework\Http\RequestIdGenerator;
use App\Framework\Http\ServerEnvironment;
use App\Framework\Http\UploadedFile;
use App\Framework\Http\UploadedFiles;
use App\Framework\Http\Uri;
use InvalidArgumentException;
/**
* Main HTTP request parser that coordinates all sub-parsers
* Parses raw HTTP requests without relying on superglobals
*/
final readonly class HttpRequestParser
{
private ParserConfig $config;
public function __construct(
ParserCache $cache,
?ParserConfig $config = null,
?QueryStringParser $queryParser = null,
?CookieParser $cookieParser = null,
?FormDataParser $formParser = null,
?FileUploadParser $fileParser = null,
?HeaderParser $headerParser = null,
?StreamingParser $streamingParser = null,
?RequestIdGenerator $requestIdGenerator = null
) {
// Use web-friendly configuration by default (less strict than default security config)
$this->config = $config ?? self::createWebConfig();
$this->queryParser = $queryParser ?? new QueryStringParser($cache, $this->config);
$this->cookieParser = $cookieParser ?? new CookieParser($cache, $this->config);
$this->formParser = $formParser ?? new FormDataParser($this->config, $this->queryParser);
$this->fileParser = $fileParser ?? new FileUploadParser($this->config);
$this->headerParser = $headerParser ?? new HeaderParser($this->config);
$this->streamingParser = $streamingParser ?? new StreamingParser($this->config);
$this->requestIdGenerator = $requestIdGenerator ?? new RequestIdGenerator();
}
/**
* Create a web-friendly parser configuration
* More permissive than the default security config for normal web traffic
*/
private static function createWebConfig(): ParserConfig
{
return new ParserConfig(
// Reasonable web limits
maxFileSize: \App\Framework\Core\ValueObjects\Byte::fromMegabytes(50),
maxFileCount: 10,
maxTotalUploadSize: \App\Framework\Core\ValueObjects\Byte::fromMegabytes(100),
maxFormDataSize: \App\Framework\Core\ValueObjects\Byte::fromMegabytes(10),
maxQueryStringLength: 8192,
maxQueryParameters: 100,
maxHeaderCount: 50,
maxCookieCount: 50,
// Relaxed security for normal web use
validateFileExtensions: false, // Don't block file extensions for normal web use
blockedFileExtensions: ['php', 'exe', 'bat', 'sh', 'asp', 'jsp'], // Don't scan content for normal web use
scanForMaliciousContent: false,
// Still block obviously dangerous extensions
strictMimeTypeValidation: false,
// Performance settings
throwOnLimitExceeded: true,
logSecurityViolations: false, // Don't log normal violations in web context
);
}
private QueryStringParser $queryParser;
private CookieParser $cookieParser;
private FormDataParser $formParser;
private FileUploadParser $fileParser;
private HeaderParser $headerParser;
private StreamingParser $streamingParser;
private RequestIdGenerator $requestIdGenerator;
/**
* Parse request from minimal PHP globals
* Only uses $_SERVER for basic info that can't be obtained otherwise
*
* @param array<string, mixed> $server Server array (usually $_SERVER)
* @param string $rawBody Raw request body (usually from php://input)
* @return Request
*/
public function parseFromGlobals(array $server, string $rawBody): Request
{
// Get basic request info (these can't be obtained any other way in PHP)
$method = $server['REQUEST_METHOD'] ?? 'GET';
$uri = $server['REQUEST_URI'] ?? '/';
#$protocol = $server['SERVER_PROTOCOL'] ?? 'HTTP/1.1';
// Parse with our custom parsers
return $this->parseRequest(
method: $method,
uri: $uri,
server: $server,
rawBody: $rawBody
);
}
/**
* Parse request from raw components
* This is the main parsing method that doesn't rely on superglobals
*
* @param string $method HTTP method
* @param string $uri Request URI (path + query)
* @param array<string, mixed> $server Server environment data
* @param string $rawBody Raw request body
* @return Request
*/
public function parseRequest(
string $method,
string $uri,
array $server,
string $rawBody
): Request {
// Security: Validate request body size
$this->validateRequestBodySize($rawBody);
// Security: Validate URI length
$this->validateUriLength($uri);
// Parse URI components
$uriComponents = parse_url($uri);
if ($uriComponents === false) {
throw new InvalidArgumentException("Invalid URI: $uri");
}
$path = $uriComponents['path'] ?? '/';
$queryString = $uriComponents['query'] ?? '';
// Normalize path
if ($path !== '/') {
$path = rtrim($path, '/');
}
// Create server environment
$serverEnvironment = new ServerEnvironment($server);
// Parse headers
$headers = $this->headerParser->parseFromServerArray($server);
// Parse cookies from Cookie header
$cookieHeader = $headers->getFirst('Cookie') ?? '';
$cookies = $this->cookieParser->parseToCookies($cookieHeader);
// Parse query parameters
$queryParams = $this->queryParser->parse($queryString);
// Determine content type
$contentType = $headers->getFirst('Content-Type', '');
// Parse body based on method and content type
$parsedBody = [];
$uploadedFiles = new UploadedFiles([]);
if (in_array($method, ['POST', 'PUT', 'PATCH'])) {
if (str_contains($contentType, 'multipart/form-data')) {
// For multipart/form-data, PHP automatically populates $_POST and $_FILES
// and makes php://input empty. Use $_POST directly in this case.
if (strlen($rawBody) === 0 && ! empty($_POST)) {
error_log("HttpRequestParser: Using \$_POST fallback for multipart/form-data");
$parsedBody = $_POST;
// Also handle $_FILES if available
if (! empty($_FILES)) {
$uploadedFiles = $this->fileParser->parseFromFilesSuperglobal($_FILES);
}
} else {
// Extract boundary
$boundary = $this->extractBoundary($contentType);
if ($boundary !== null) {
// Use streaming parser for large requests
if ($this->shouldUseStreamingParser($rawBody)) {
[$parsedBody, $uploadedFiles] = $this->parseWithStreamingParser($rawBody, $boundary);
} else {
// Parse both form fields and files with regular parsers
$parsedBody = $this->formParser->parse($contentType, $rawBody);
$uploadedFiles = $this->fileParser->parseMultipart($rawBody, $boundary);
}
}
}
} else {
// Parse as regular form data
$parsedBody = $this->formParser->parse($contentType, $rawBody);
}
}
// Create a method enum
$methodEnum = Method::tryFrom($method) ?? Method::GET;
// Check for method override in parsed body
if ($methodEnum === Method::POST && isset($parsedBody['_method'])) {
$overrideMethod = Method::tryFrom($parsedBody['_method']);
if ($overrideMethod !== null) {
$methodEnum = $overrideMethod;
}
}
// Create RequestBody object
// For GET requests, pass query params as parsed data
$bodyData = $methodEnum === Method::GET ? $queryParams : $parsedBody;
$requestBody = new RequestBody(
method: $methodEnum,
headers: $headers,
body: $rawBody,
parsedData: $bodyData
);
// Generate request ID
$requestId = $this->requestIdGenerator->generate();
// Create the request
return new HttpRequest(
method: $methodEnum,
headers: $headers,
body: $rawBody,
path: $path,
queryParams: $queryParams,
files: $uploadedFiles,
cookies: $cookies,
server: $serverEnvironment,
id: $requestId,
parsedBody: $requestBody
);
}
/**
* Parse a raw HTTP request string (for testing or special cases)
*
* @param string $rawRequest Complete raw HTTP request including headers and body
* @return Request
*/
public function parseRawHttpRequest(string $rawRequest): Request
{
// Split request into headers and body
$parts = explode("\r\n\r\n", $rawRequest, 2);
$headerSection = $parts[0];
$body = $parts[1] ?? '';
// Parse request line
$lines = explode("\r\n", $headerSection);
$requestLine = array_shift($lines);
if (! preg_match('/^(\S+)\s+(\S+)\s+(\S+)$/', $requestLine, $matches)) {
throw new InvalidArgumentException("Invalid request line: $requestLine");
}
$method = $matches[1];
$uri = $matches[2];
$protocol = $matches[3];
// Parse headers from remaining lines
$headerString = implode("\r\n", $lines);
$headers = $this->headerParser->parseRawHeaders($headerString);
// Create minimal server array
$server = [
'REQUEST_METHOD' => $method,
'REQUEST_URI' => $uri,
'SERVER_PROTOCOL' => $protocol,
];
// Add some headers to server array for compatibility
if ($headers->has('Host')) {
$server['HTTP_HOST'] = $headers->getFirst('Host');
}
if ($headers->has('User-Agent')) {
$server['HTTP_USER_AGENT'] = $headers->getFirst('User-Agent');
}
return $this->parseRequest($method, $uri, $server, $body);
}
/**
* Extract boundary from Content-Type header
*
* @param string $contentType Content-Type header value
* @return string|null Boundary or null if not found
*/
private function extractBoundary(string $contentType): ?string
{
if (preg_match('/boundary=([^;]+)/', $contentType, $matches)) {
return trim($matches[1], ' "');
}
return null;
}
/**
* Validate request body size against security limits
*
* @param string $rawBody Raw request body
* @throws ParserSecurityException If body size exceeds limits
*/
private function validateRequestBodySize(string $rawBody): void
{
$bodySize = strlen($rawBody);
// Check against maximum total upload size (includes form data + files)
if ($bodySize > $this->config->maxTotalUploadSize->toBytes()) {
throw ParserSecurityException::requestBodySizeExceeded(
$bodySize,
$this->config->maxTotalUploadSize->toBytes()
);
}
}
/**
* Validate URI length against security limits
*
* @param string $uri Request URI
* @throws ParserSecurityException If URI is too long
*/
private function validateUriLength(string $uri): void
{
$uriLength = strlen($uri);
// Most web servers have a limit around 8KB for the request line
// We use a conservative 4KB limit for security
$maxUriLength = 4096;
if ($uriLength > $maxUriLength) {
throw ParserSecurityException::simple(
"URI too long: {$uriLength} characters > {$maxUriLength} maximum"
);
}
}
/**
* Determine if streaming parser should be used based on request size
*
* @param string $rawBody
* @return bool
*/
private function shouldUseStreamingParser(string $rawBody): bool
{
// Use streaming parser for requests larger than 10MB
$streamingThreshold = 10 * 1024 * 1024; // 10MB
return strlen($rawBody) > $streamingThreshold;
}
/**
* Parse multipart data using the streaming parser
*
* @param string $rawBody
* @param string $boundary
* @return array{0: array<string, mixed>, 1: UploadedFiles}
*/
private function parseWithStreamingParser(string $rawBody, string $boundary): array
{
// Create stream from raw body
$stream = fopen('php://memory', 'r+');
fwrite($stream, $rawBody);
rewind($stream);
$formData = [];
$files = [];
try {
// Process all parts using the generator
foreach ($this->streamingParser->streamMultipart($stream, $boundary) as $part) {
if ($part['type'] === 'file') {
// Handle file upload
if (isset($part['stream'])) {
// Create temporary file
$tempPath = tempnam(sys_get_temp_dir(), 'upload_');
if ($tempPath === false) {
throw new \RuntimeException('Failed to create temporary file');
}
$tempHandle = fopen($tempPath, 'w');
if ($tempHandle === false) {
throw new \RuntimeException('Failed to open temporary file for writing');
}
stream_copy_to_stream($part['stream'], $tempHandle);
fclose($tempHandle);
fclose($part['stream']);
// Create UploadedFile
$uploadedFile = new UploadedFile(
name: $part['filename'] ?? '',
type: $part['headers']['Content-Type'] ?? 'application/octet-stream',
size: filesize($tempPath) ?: 0,
tmpName: $tempPath,
error: \App\Framework\Http\UploadError::OK,
skipValidation: true
);
// Handle array notation
$name = $part['name'];
if (str_contains($name, '[')) {
$this->addArrayFile($files, $name, $uploadedFile);
} else {
$files[$name] = $uploadedFile;
}
}
} else {
// Handle form field
$name = $part['name'];
$value = $part['data'] ?? '';
// Handle array notation in form fields
if (str_contains($name, '[')) {
$this->addArrayValue($formData, $name, $value);
} else {
$formData[$name] = $value;
}
}
}
} finally {
fclose($stream);
}
return [$formData, new UploadedFiles($files)];
}
/**
* Add file to array structure based on field name
*
* @param array<string, mixed> &$files
* @param string $name
* @param UploadedFile $file
*/
private function addArrayFile(array &$files, string $name, UploadedFile $file): void
{
// Parse array notation like "files[0]" or "files[avatar]"
if (preg_match('/^([^\[]+)\[([^]]*)](.*)$/', $name, $matches)) {
$baseName = $matches[1];
$key = $matches[2];
$remainder = $matches[3];
if (! isset($files[$baseName])) {
$files[$baseName] = [];
}
if ($remainder) {
// Nested array
if ($key === '') {
$key = count($files[$baseName]);
}
$this->addArrayFile($files[$baseName], $key . $remainder, $file);
} else {
// Final level
if ($key === '') {
$files[$baseName][] = $file;
} else {
$files[$baseName][$key] = $file;
}
}
}
}
/**
* Add value to array structure based on field name
*
* @param array<string, mixed> &$data
* @param string $name
* @param string $value
*/
private function addArrayValue(array &$data, string $name, string $value): void
{
// Parse array notation like "fields[0]" or "fields[name]"
if (preg_match('/^([^\[]+)\[([^]]*)](.*)$/', $name, $matches)) {
$baseName = $matches[1];
$key = $matches[2];
$remainder = $matches[3];
if (! isset($data[$baseName])) {
$data[$baseName] = [];
}
if ($remainder) {
// Nested array
if ($key === '') {
$key = count($data[$baseName]);
}
$this->addArrayValue($data[$baseName], $key . $remainder, $value);
} else {
// Final level
if ($key === '') {
$data[$baseName][] = $value;
} else {
$data[$baseName][$key] = $value;
}
}
}
}
}

View File

@@ -0,0 +1,298 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Parser;
use App\Framework\Cache\Cache;
use App\Framework\Cache\CacheItem;
use App\Framework\Cache\CacheKey;
use App\Framework\Core\ValueObjects\Duration;
/**
* High-performance caching system for parser results using Framework Cache
* Reduces repeated parsing overhead for common patterns
*/
final readonly class ParserCache
{
private const string CACHE_PREFIX = 'http_parser:';
private const int MAX_CACHE_KEY_LENGTH = 200;
private Duration $ttl;
public function __construct(
private Cache $cache,
?Duration $ttl = null
) {
$this->ttl = $ttl ?? Duration::fromHours(1);
}
/**
* Get cached query string parse result
*
* @param string $queryString
* @return array<string, mixed>|null
*/
public function getQueryString(string $queryString): ?array
{
if (! $this->shouldCache($queryString)) {
return null;
}
$cacheKey = $this->generateCacheKey('query', $queryString);
// Check if cached first, then get the value
if ($this->cache->has($cacheKey)) {
$cacheItem = $this->cache->get($cacheKey);
if ($cacheItem->isHit && is_array($cacheItem->value)) {
return $cacheItem->value;
}
}
return null;
}
/**
* Cache query string parse result
*
* @param string $queryString
* @param array<string, mixed> $result
*/
public function setQueryString(string $queryString, array $result): void
{
if (! $this->shouldCache($queryString)) {
return;
}
$cacheKey = $this->generateCacheKey('query', $queryString);
$this->cache->set(CacheItem::forSet($cacheKey, $result, $this->ttl));
}
/**
* Get cached header parse result
*
* @param string $headerString
* @return array<string, mixed>|null
*/
public function getHeaders(string $headerString): ?array
{
if (! $this->shouldCache($headerString)) {
return null;
}
$cacheKey = $this->generateCacheKey('headers', $headerString);
if ($this->cache->has($cacheKey)) {
$cacheItem = $this->cache->get($cacheKey);
if ($cacheItem->isHit && is_array($cacheItem->value)) {
return $cacheItem->value;
}
}
return null;
}
/**
* Cache header parse result
*
* @param string $headerString
* @param array<string, mixed> $result
*/
public function setHeaders(string $headerString, array $result): void
{
if (! $this->shouldCache($headerString)) {
return;
}
$cacheKey = $this->generateCacheKey('headers', $headerString);
$this->cache->set(CacheItem::forSet($cacheKey, $result, $this->ttl));
}
/**
* Get cached cookie parse result
*
* @param string $cookieString
* @return array<string, mixed>|null
*/
public function getCookies(string $cookieString): ?array
{
if (! $this->shouldCache($cookieString)) {
return null;
}
$cacheKey = $this->generateCacheKey('cookies', $cookieString);
if ($this->cache->has($cacheKey)) {
$cacheItem = $this->cache->get($cacheKey);
if ($cacheItem->isHit && is_array($cacheItem->value)) {
return $cacheItem->value;
}
}
return null;
}
/**
* Cache cookie parse result
*
* @param string $cookieString
* @param array<string, mixed> $result
*/
public function setCookies(string $cookieString, array $result): void
{
if (! $this->shouldCache($cookieString)) {
return;
}
$cacheKey = $this->generateCacheKey('cookies', $cookieString);
$this->cache->set(CacheItem::forSet($cacheKey, $result, $this->ttl));
}
/**
* Cache parsed multipart boundary for reuse
*
* @param string $contentType
* @return string|null
*/
public function getBoundary(string $contentType): ?string
{
$cacheKey = $this->generateCacheKey('boundary', $contentType);
if ($this->cache->has($cacheKey)) {
$cacheItem = $this->cache->get($cacheKey);
if ($cacheItem->isHit && is_string($cacheItem->value)) {
return $cacheItem->value;
}
}
return null;
}
/**
* Cache multipart boundary
*
* @param string $contentType
* @param string $boundary
*/
public function setBoundary(string $contentType, string $boundary): void
{
$cacheKey = $this->generateCacheKey('boundary', $contentType);
$this->cache->set(CacheItem::forSet($cacheKey, $boundary, $this->ttl));
}
/**
* Clear all parser caches
* Note: Framework Cache doesn't support pattern deletion, so this clears all cache
*/
public function clearAll(): void
{
$this->cache->clear();
}
/**
* Clear specific cache type
* Note: Framework Cache doesn't support pattern deletion, so this clears all cache
*
* @param string $type One of: query, headers, cookies, boundary
*/
public function clearType(string $type): void
{
// Since we can't delete by pattern, we clear all cache
// This is a limitation of the current Cache interface
$this->cache->clear();
}
/**
* Get cache statistics
*
* @return array<string, mixed>
*/
public function getStats(): array
{
// Try to get stats from cache implementation
if (method_exists($this->cache, 'getStats')) {
$cacheStats = $this->cache->getStats();
// Filter for our keys if possible
return [
'cache_backend' => get_class($this->cache),
'ttl' => $this->ttl->toSeconds(),
'prefix' => self::CACHE_PREFIX,
'backend_stats' => $cacheStats,
];
}
return [
'cache_backend' => get_class($this->cache),
'ttl' => $this->ttl->toSeconds(),
'prefix' => self::CACHE_PREFIX,
];
}
/**
* Generate cache key for input data
*/
private function generateCacheKey(string $type, string $input): CacheKey
{
$baseKey = self::CACHE_PREFIX . $type . ':';
// Use hash for long inputs to prevent cache key issues
if (strlen($input) > self::MAX_CACHE_KEY_LENGTH) {
return CacheKey::fromString($baseKey . 'hash_' . hash('xxh64', $input));
}
// Replace problematic characters for cache keys
$cleanInput = preg_replace('/[^a-zA-Z0-9._-]/', '_', $input);
return CacheKey::fromString($baseKey . $cleanInput);
}
/**
* Check if caching should be used for given input size
*
* @param string $input
* @return bool
*/
private function shouldCache(string $input): bool
{
$length = strlen($input);
// Don't cache very short strings (overhead not worth it)
if ($length < 10) {
return false;
}
// Don't cache very long strings (memory usage)
if ($length > 4096) {
return false;
}
// Don't cache if input contains sensitive data patterns
if ($this->containsSensitiveData($input)) {
return false;
}
return true;
}
/**
* Check if input contains potentially sensitive data that shouldn't be cached
*
* @param string $input
* @return bool
*/
private function containsSensitiveData(string $input): bool
{
$sensitivePatterns = [
'/password/i',
'/token/i',
'/secret/i',
'/key/i',
'/auth/i',
'/session/i',
];
return array_any($sensitivePatterns, fn ($pattern) => preg_match($pattern, $input));
}
}

View File

@@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Parser;
use App\Framework\Core\ValueObjects\Byte;
/**
* Configuration for HTTP parsers with security and performance limits
*/
final readonly class ParserConfig
{
public function __construct(
// File upload limits
public Byte $maxFileSize = new Byte(100 * 1024 * 1024), // 100MB
public int $maxFileCount = 20,
public Byte $maxTotalUploadSize = new Byte(500 * 1024 * 1024), // 500MB
// Form data limits
public int $maxFieldCount = 1000,
public int $maxFieldNameLength = 1000,
public int $maxFieldValueLength = 1024 * 1024, // 1MB
public Byte $maxFormDataSize = new Byte(10 * 1024 * 1024), // 10MB
// Query string limits
public int $maxQueryStringLength = 8192, // 8KB
public int $maxQueryParameters = 1000,
// Header limits
public int $maxHeaderCount = 100,
public int $maxHeaderNameLength = 256,
public int $maxHeaderValueLength = 8192,
public Byte $maxTotalHeaderSize = new Byte(64 * 1024), // 64KB
// Cookie limits
public int $maxCookieCount = 50,
public int $maxCookieNameLength = 256,
public int $maxCookieValueLength = 4096,
// Multipart limits
public int $maxMultipartParts = 100,
public int $maxBoundaryLength = 256,
// General limits
public Byte $maxRequestBodySize = new Byte(100 * 1024 * 1024), // 100MB
public int $maxUriLength = 8192,
// Security settings
public bool $validateFileExtensions = true,
public array $allowedFileExtensions = ['jpg', 'jpeg', 'png', 'gif', 'pdf', 'txt', 'doc', 'docx'],
public array $blockedFileExtensions = ['php', 'js', 'html', 'exe', 'bat', 'sh'],
public bool $scanForMaliciousContent = true,
public bool $strictMimeTypeValidation = true,
// Performance settings
public bool $enableStreamingForLargeFiles = true,
public Byte $streamingThreshold = new Byte(10 * 1024 * 1024), // 10MB
public string $tempDirectory = '',
// Error handling
public bool $throwOnLimitExceeded = true,
public bool $logSecurityViolations = true,
) {
}
/**
* Create a development-friendly configuration with relaxed limits
*/
public static function development(): self
{
return new self(
maxFileSize: Byte::fromMegabytes(50),
maxFileCount: 10,
maxFormDataSize: Byte::fromMegabytes(5),
validateFileExtensions: false,
scanForMaliciousContent: false,
strictMimeTypeValidation: false,
throwOnLimitExceeded: false,
logSecurityViolations: false,
);
}
/**
* Create a production-ready configuration with strict limits
*/
public static function production(): self
{
return new self(
maxFileSize: Byte::fromMegabytes(10),
maxFileCount: 5,
maxTotalUploadSize: Byte::fromMegabytes(50),
maxFieldCount: 100,
maxFormDataSize: Byte::fromMegabytes(2),
maxQueryParameters: 100,
validateFileExtensions: true,
blockedFileExtensions: ['php', 'js', 'html', 'exe', 'bat', 'sh', 'asp', 'jsp', 'py', 'rb'],
scanForMaliciousContent: true,
strictMimeTypeValidation: true,
throwOnLimitExceeded: true,
logSecurityViolations: true,
);
}
/**
* Create a high-security configuration for sensitive applications
*/
public static function highSecurity(): self
{
return new self(
maxFileSize: Byte::fromMegabytes(5),
maxFileCount: 3,
maxTotalUploadSize: Byte::fromMegabytes(15),
maxFieldCount: 50,
maxFormDataSize: Byte::fromMegabytes(1),
maxQueryParameters: 50,
maxHeaderCount: 50,
maxCookieCount: 20,
validateFileExtensions: true,
allowedFileExtensions: ['jpg', 'jpeg', 'png', 'pdf', 'txt'],
blockedFileExtensions: ['php', 'js', 'html', 'exe', 'bat', 'sh', 'asp', 'jsp', 'py', 'rb', 'pl', 'cgi'],
scanForMaliciousContent: true,
strictMimeTypeValidation: true,
throwOnLimitExceeded: true,
logSecurityViolations: true,
);
}
/**
* Get effective temp directory
*/
public function getTempDirectory(): string
{
return $this->tempDirectory ?: sys_get_temp_dir();
}
/**
* Check if file extension is allowed
*/
public function isFileExtensionAllowed(string $filename): bool
{
if (! $this->validateFileExtensions) {
return true;
}
$extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
// Check blocked list first
if (in_array($extension, $this->blockedFileExtensions, true)) {
return false;
}
// If allowed list is empty, allow all (except blocked)
if (empty($this->allowedFileExtensions)) {
return true;
}
// Check allowed list
return in_array($extension, $this->allowedFileExtensions, true);
}
/**
* Validate file size against limits
*/
public function validateFileSize(Byte $size): bool
{
return $size->isLessThanOrEqual($this->maxFileSize);
}
/**
* Validate total upload size
*/
public function validateTotalUploadSize(Byte $totalSize): bool
{
return $totalSize->isLessThanOrEqual($this->maxTotalUploadSize);
}
/**
* Validate form data size
*/
public function validateFormDataSize(Byte $size): bool
{
return $size->isLessThanOrEqual($this->maxFormDataSize);
}
/**
* Validate request body size
*/
public function validateRequestBodySize(Byte $size): bool
{
return $size->isLessThanOrEqual($this->maxRequestBodySize);
}
/**
* Check if streaming should be used for given size
*/
public function shouldUseStreaming(Byte $size): bool
{
return $this->enableStreamingForLargeFiles && $size->greaterThan($this->streamingThreshold);
}
/**
* Create a default configuration with balanced settings
*/
public static function default(): self
{
return new self();
}
}

View File

@@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Parser;
use App\Framework\Http\Parser\Exception\ParserSecurityException;
/**
* Parser for URL query strings with security validation
* Handles complex cases like arrays, nested values, and special characters
*/
final readonly class QueryStringParser
{
public function __construct(
private ParserCache $cache,
private ParserConfig $config,
) {
}
/**
* Parse a query string into an associative array
*
* @param string $queryString The raw query string (without leading ?)
* @return array<string, mixed> Parsed parameters
* @throws ParserSecurityException
*/
public function parse(string $queryString): array
{
if ($queryString === '') {
return [];
}
// Try cache first
$cached = $this->cache->getQueryString($queryString);
if ($cached !== null) {
return $cached;
}
// Security validation
if (strlen($queryString) > $this->config->maxQueryStringLength) {
throw ParserSecurityException::queryStringSizeExceeded(
strlen($queryString),
$this->config->maxQueryStringLength
);
}
$params = [];
$pairs = explode('&', $queryString);
// Check parameter count
if (count($pairs) > $this->config->maxQueryParameters) {
throw ParserSecurityException::queryParameterCountExceeded(
count($pairs),
$this->config->maxQueryParameters
);
}
foreach ($pairs as $pair) {
if ($pair === '') {
continue;
}
$parts = explode('=', $pair, 2);
$key = $this->decodeComponent($parts[0]);
$value = isset($parts[1]) ? $this->decodeComponent($parts[1]) : '';
$params = $this->assignValue($params, $key, $value);
}
// Cache the result before returning
$this->cache->setQueryString($queryString, $params);
return $params;
}
/**
* Decode URL component with proper error handling
*/
private function decodeComponent(string $component): string
{
// Replace + with space before URL decoding (form encoding standard)
$component = str_replace('+', ' ', $component);
// Decode percent-encoded characters
return rawurldecode($component);
}
/**
* Assign value to params array, handling array notation
*
* @param array<string, mixed> $params Params array
* @param string $key The parameter key (may include [] notation)
* @param string $value The parameter value
* @return array<string, mixed> Updated params array
*/
private function assignValue(array $params, string $key, string $value): array
{
// Handle array notation like key[] or key[index]
if (preg_match('/^([^\[]+)(\[.*])$/', $key, $matches)) {
$baseKey = $matches[1];
$arrayPart = $matches[2];
return $this->assignArrayValue($params, $baseKey, $arrayPart, $value);
} else {
// Simple key=value
$params[$key] = $value;
return $params;
}
}
/**
* Handle array notation in query parameters
*/
private function assignArrayValue(array $params, string $baseKey, string $arrayPart, string $value): array
{
// Initialize array if not exists
if (! isset($params[$baseKey])) {
$params[$baseKey] = [];
}
// Parse array notation: [], [key], [key1][key2], etc.
if ($arrayPart === '[]') {
// Simple array push
if (! is_array($params[$baseKey])) {
$params[$baseKey] = [$params[$baseKey]];
}
$params[$baseKey][] = $value;
} else {
// Parse nested keys
$keys = $this->parseArrayKeys($arrayPart);
$params[$baseKey] = $this->assignNestedValue($params[$baseKey], $keys, $value);
}
return $params;
}
/**
* Parse array keys from notation like [key1][key2][key3]
*
* @return string[] Array of keys
*/
private function parseArrayKeys(string $arrayPart): array
{
preg_match_all('/\[([^]]*)]/', $arrayPart, $matches);
return $matches[1];
}
/**
* Assign value to nested array structure (immutable)
*
* @param mixed $target Target array/value
* @param string[] $keys Array of keys to traverse
* @param string $value Value to assign
* @return mixed Updated structure
*/
private function assignNestedValue(mixed $target, array $keys, string $value): mixed
{
if (empty($keys)) {
return $value;
}
$key = array_shift($keys);
if (! is_array($target)) {
$target = [];
}
if ($key === '') {
// Empty key means array push
$index = count($target);
$target[$index] = $this->assignNestedValue($target[$index] ?? null, $keys, $value);
} else {
$target[$key] = $this->assignNestedValue($target[$key] ?? null, $keys, $value);
}
return $target;
}
}

View File

@@ -0,0 +1,344 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Parser;
use App\Framework\Http\Parser\Exception\ParserSecurityException;
use App\Framework\Http\UploadedFile;
use App\Framework\Http\UploadError;
/**
* True streaming parser using PHP generators for memory-efficient processing
* Processes multipart data without loading entire request into memory
*/
final class StreamingParser
{
private const int DEFAULT_CHUNK_SIZE = 8192; // 8KB chunks
private const string BOUNDARY_PREFIX = '--';
private const string CRLF = "\r\n";
private const string DOUBLE_CRLF = "\r\n\r\n";
public function __construct(
private readonly ParserConfig $config = new ParserConfig()
) {
}
/**
* Stream multipart data using generators
* Each part is yielded as it's completely read from the stream
*
* @param resource $stream Input stream (e.g., php://input)
* @param string $boundary Multipart boundary
* @param int $chunkSize Size of chunks to read
* @return \Generator<int, array{type: string, name: string, filename?: string, headers: array<string, string>, stream?: resource, data?: string}>
*/
public function streamMultipart($stream, string $boundary, int $chunkSize = self::DEFAULT_CHUNK_SIZE): \Generator
{
if (! is_resource($stream)) {
throw new \InvalidArgumentException('First parameter must be a valid stream resource');
}
$partCount = 0;
$buffer = '';
$inPart = false;
$currentPart = [];
$partHeaders = [];
$partData = '';
$tempFile = null;
// Boundary markers
$boundaryDelimiter = self::BOUNDARY_PREFIX . $boundary;
$finalBoundary = $boundaryDelimiter . self::BOUNDARY_PREFIX;
while (! feof($stream)) {
$chunk = fread($stream, $chunkSize);
if ($chunk === false) {
break;
}
$buffer .= $chunk;
// Process buffer line by line
while (($lineEnd = strpos($buffer, self::CRLF)) !== false) {
$line = substr($buffer, 0, $lineEnd);
$buffer = substr($buffer, $lineEnd + 2);
// Check for boundary
if ($line === $boundaryDelimiter || $line === $finalBoundary) {
// Yield previous part if exists
if ($inPart && ! empty($currentPart)) {
yield $this->finalizePart($currentPart, $partHeaders, $partData, $tempFile);
$partCount++;
if ($partCount > $this->config->maxFileCount) {
throw ParserSecurityException::maxPartsExceeded($partCount, $this->config->maxFileCount);
}
}
// Check if this is the final boundary
if ($line === $finalBoundary) {
return;
}
// Start new part
$inPart = true;
$currentPart = [];
$partHeaders = [];
$partData = '';
$tempFile = null;
continue;
}
// If we're in a part
if ($inPart) {
// Still reading headers
if (empty($currentPart)) {
if ($line === '') {
// End of headers, parse them
$currentPart = $this->parsePartHeaders($partHeaders);
// If it's a file, create temp stream
if (isset($currentPart['filename'])) {
$tempFile = tmpfile();
if ($tempFile === false) {
throw new \RuntimeException('Failed to create temporary file for upload');
}
}
} else {
// Collect header line
$partHeaders[] = $line;
}
} else {
// Reading part data
if (isset($currentPart['filename']) && $tempFile !== null) {
// Write to temp file for file uploads
fwrite($tempFile, $line . self::CRLF);
} else {
// Accumulate in memory for form fields
$partData .= $line . self::CRLF;
// Check field size limit
if (strlen($partData) > $this->config->maxFieldValueLength) {
throw ParserSecurityException::fieldValueTooLong(
$currentPart['name'] ?? 'unknown',
strlen($partData),
$this->config->maxFieldValueLength
);
}
}
}
}
}
// Handle remaining data in buffer that might be part of file content
if ($inPart && ! empty($currentPart) && strlen($buffer) > 0) {
// Check if buffer might contain a boundary
$boundaryPos = strpos($buffer, $boundaryDelimiter);
if ($boundaryPos === false || $boundaryPos > strlen($boundaryDelimiter)) {
// Safe to write some data
$safeLength = $boundaryPos !== false ? $boundaryPos : strlen($buffer) - strlen($boundaryDelimiter);
if ($safeLength > 0) {
$safeData = substr($buffer, 0, $safeLength);
if (isset($currentPart['filename']) && $tempFile !== null) {
fwrite($tempFile, $safeData);
} else {
$partData .= $safeData;
}
$buffer = substr($buffer, $safeLength);
}
}
}
}
// Yield last part if exists
if ($inPart && ! empty($currentPart)) {
yield $this->finalizePart($currentPart, $partHeaders, $partData, $tempFile);
}
}
/**
* Parse headers from a multipart part
*
* @param array<string> $headerLines
* @return array<string, mixed>
*/
private function parsePartHeaders(array $headerLines): array
{
$headers = [];
$result = [];
// Parse header lines
foreach ($headerLines as $line) {
if (strpos($line, ':') !== false) {
[$name, $value] = explode(':', $line, 2);
$headers[trim($name)] = trim($value);
}
}
// Parse Content-Disposition
if (isset($headers['Content-Disposition'])) {
$disposition = $headers['Content-Disposition'];
// Extract name
if (preg_match('/name="([^"]+)"/', $disposition, $matches)) {
$result['name'] = $matches[1];
}
// Extract filename
if (preg_match('/filename="([^"]+)"/', $disposition, $matches)) {
$result['filename'] = $matches[1];
}
}
// Add content type if present
if (isset($headers['Content-Type'])) {
$result['content_type'] = $headers['Content-Type'];
}
$result['headers'] = $headers;
return $result;
}
/**
* Finalize a part for yielding
*
* @param array<string, mixed> $part
* @param array<string> $headers
* @param string $data
* @param resource|null $tempFile
* @return array{type: string, name: string, filename?: string, headers: array<string, string>, stream?: resource, data?: string}
*/
private function finalizePart(array $part, array $headers, string $data, $tempFile): array
{
$result = [
'type' => isset($part['filename']) ? 'file' : 'field',
'name' => $part['name'] ?? 'unknown',
'headers' => $part['headers'] ?? [],
];
if (isset($part['filename'])) {
$result['filename'] = $part['filename'];
if ($tempFile !== null) {
// Rewind temp file to beginning
rewind($tempFile);
$result['stream'] = $tempFile;
// Validate file size
$stats = fstat($tempFile);
if ($stats !== false && $stats['size'] > $this->config->maxFileSize->toBytes()) {
fclose($tempFile);
throw ParserSecurityException::fileSizeExceeded(
$part['filename'],
$stats['size'],
$this->config->maxFileSize->toBytes()
);
}
}
} else {
// Remove trailing CRLF from field data
$result['data'] = rtrim($data, "\r\n");
}
return $result;
}
/**
* Create UploadedFile objects from a stream
* This collects all file parts from the generator
*
* @param resource $stream
* @param string $boundary
* @return array<string, UploadedFile|array<UploadedFile>>
*/
public function parseFilesFromStream($stream, string $boundary): array
{
$files = [];
foreach ($this->streamMultipart($stream, $boundary) as $part) {
if ($part['type'] === 'file' && isset($part['stream'])) {
// Create temporary file path
$tempPath = tempnam(sys_get_temp_dir(), 'upload_');
if ($tempPath === false) {
throw new \RuntimeException('Failed to create temporary file');
}
// Copy stream to temp file
$tempHandle = fopen($tempPath, 'w');
if ($tempHandle === false) {
throw new \RuntimeException('Failed to open temporary file for writing');
}
stream_copy_to_stream($part['stream'], $tempHandle);
fclose($tempHandle);
fclose($part['stream']);
// Get file size
$fileSize = filesize($tempPath) ?: 0;
// Create UploadedFile
$uploadedFile = new UploadedFile(
name: $part['filename'] ?? '',
type: $part['headers']['Content-Type'] ?? 'application/octet-stream',
size: $fileSize,
tmpName: $tempPath,
error: UploadError::OK,
skipValidation: true
);
// Handle array notation in name
$name = $part['name'];
if (str_contains($name, '[')) {
// Parse array notation and build nested structure
$this->addArrayFile($files, $name, $uploadedFile);
} else {
$files[$name] = $uploadedFile;
}
}
}
return $files;
}
/**
* Add file to array structure based on field name
*
* @param array<string, mixed> &$files
* @param string $name
* @param UploadedFile $file
*/
private function addArrayFile(array &$files, string $name, UploadedFile $file): void
{
// Parse array notation like "files[0]" or "files[avatar]"
if (preg_match('/^([^\[]+)\[([^\]]*)\](.*)$/', $name, $matches)) {
$baseName = $matches[1];
$key = $matches[2];
$remainder = $matches[3];
if (! isset($files[$baseName])) {
$files[$baseName] = [];
}
if ($remainder) {
// Nested array
if ($key === '') {
$key = count($files[$baseName]);
}
$this->addArrayFile($files[$baseName], $key . $remainder, $file);
} else {
// Final level
if ($key === '') {
$files[$baseName][] = $file;
} else {
$files[$baseName][$key] = $file;
}
}
}
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
final readonly class Query
{
public function __construct(
private array $query
) {
}
public function get(string $key, ?string $default = null): ?string
{
return $this->query[$key] ?? $default;
}
public function toArray(): array
{
return $this->query;
}
public function has(string $key): bool
{
return isset($this->query[$key]);
}
}

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
@@ -6,6 +7,7 @@ namespace App\Framework\Http;
final readonly class Range
{
public int $length;
public function __construct(
public int $start,
public int $end,

View File

@@ -1,100 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\RateLimiter;
/**
* Konfiguration für verschiedene Rate Limiting Strategien.
* Diese Klasse ermöglicht die Definition unterschiedlicher Rate-Limits
* für verschiedene Teile der Anwendung.
*/
final class RateLimiterConfig
{
/**
* @var array<string, array{limit: int, window: int}> Pfad-Muster (Regex) => Limit-Konfiguration
*/
private array $pathLimits = [];
/**
* @param int $defaultLimit Standardmäßige Anzahl an erlaubten Anfragen im Zeitfenster
* @param int $windowSeconds Länge des Zeitfensters in Sekunden
*/
public function __construct(
private readonly int $defaultLimit = 60,
private readonly int $windowSeconds = 60
) {
}
/**
* Fügt ein spezifisches Limit für einen Pfad hinzu.
*
* @param string $pathPattern Ein regulärer Ausdruck, der den Pfad beschreibt
* @param int $limit Anzahl der erlaubten Anfragen
* @param int|null $window Zeitfenster in Sekunden (optional, nutzt Standard wenn nicht angegeben)
* @return self
*/
public function addPathLimit(string $pathPattern, int $limit, ?int $window = null): self
{
$this->pathLimits[$pathPattern] = [
'limit' => $limit,
'window' => $window ?? $this->windowSeconds
];
return $this;
}
/**
* Fügt ein strengeres Limit für API-Endpunkte hinzu.
*
* @param int $limit Anzahl der erlaubten Anfragen
* @param int|null $window Zeitfenster in Sekunden
* @return self
*/
public function withApiLimit(int $limit, ?int $window = null): self
{
return $this->addPathLimit('#^/api/#', $limit, $window);
}
/**
* Fügt ein strengeres Limit für Authentifizierungs-Endpunkte hinzu.
*
* @param int $limit Anzahl der erlaubten Anfragen
* @param int|null $window Zeitfenster in Sekunden
* @return self
*/
public function withAuthLimit(int $limit, ?int $window = null): self
{
return $this->addPathLimit('#^/(login|register|password-reset)#', $limit, $window);
}
/**
* Gibt das Standard-Limit zurück.
*
* @return int
*/
public function getDefaultLimit(): int
{
return $this->defaultLimit;
}
/**
* Gibt das Standard-Zeitfenster zurück.
*
* @return int
*/
public function getWindowSeconds(): int
{
return $this->windowSeconds;
}
/**
* Gibt alle pfadspezifischen Limits zurück.
*
* @return array<string, array{limit: int, window: int}>
*/
public function getPathLimits(): array
{
return $this->pathLimits;
}
}

View File

@@ -1,90 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\RateLimiter;
use Predis\Client as RedisClient;
/**
* Redis-basierte Implementierung eines Rate-Limiters.
* Verwendet Redis Sorted Sets für präzises Sliding Window Rate Limiting.
*/
final readonly class RedisRateLimiter
{
private const string KEY_PREFIX = 'rate_limit:';
public function __construct(
private RedisClient $redis
) {
}
/**
* Prüft, ob eine Anfrage das Rate-Limit überschreitet.
*
* @param string $identifier Eindeutiger Identifikator (z.B. IP-Adresse)
* @param string $action Optional: Name der Aktion (z.B. 'login', 'api')
* @param int $limit Anzahl der erlaubten Anfragen im Zeitfenster
* @param int $windowSeconds Länge des Zeitfensters in Sekunden
* @return array{allowed: bool, current: int, remaining: int, reset: int} Ergebnis der Prüfung
*/
public function check(
string $identifier,
string $action = 'default',
int $limit = 60,
int $windowSeconds = 60
): array {
$key = $this->getKey($identifier, $action);
$now = time();
// Anfragen entfernen, die außerhalb des Zeitfensters liegen
$this->redis->zremrangebyscore($key, 0, $now - $windowSeconds);
// Aktuelle Anzahl der Anfragen im Zeitfenster
$current = (int)$this->redis->zcard($key);
// Wenn noch unter dem Limit, diese Anfrage hinzufügen
if ($current < $limit) {
$this->redis->zadd($key, [$now => $now]);
$this->redis->expire($key, $windowSeconds);
$current++;
}
// Zeitpunkt berechnen, wann das Limit zurückgesetzt wird
$oldestRequest = $this->redis->zrange($key, 0, 0, ['WITHSCORES' => true]);
$resetTime = 0;
if (!empty($oldestRequest)) {
$oldestTimestamp = (int)reset($oldestRequest);
$resetTime = $oldestTimestamp + $windowSeconds;
}
return [
'allowed' => $current <= $limit,
'current' => $current,
'remaining' => max(0, $limit - $current),
'reset' => $resetTime - $now
];
}
/**
* Erzeugt einen Reset des Rate-Limits für einen bestimmten Identifikator.
* Nützlich z.B. nach erfolgreicher Authentifizierung.
*
* @param string $identifier Eindeutiger Identifikator
* @param string $action Optional: Name der Aktion
*/
public function reset(string $identifier, string $action = 'default'): void
{
$key = $this->getKey($identifier, $action);
$this->redis->del($key);
}
/**
* Erzeugt den Redis-Schlüssel für den gegebenen Identifikator und die Aktion.
*/
private function getKey(string $identifier, string $action): string
{
return self::KEY_PREFIX . "{$identifier}:{$action}";
}
}

View File

@@ -1,46 +1,117 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
/**
* Represents the parsed body of an HTTP request
* No longer directly accesses superglobals
*/
final readonly class RequestBody
{
public array $data;
public function __construct(Method $method, Headers $headers, string $body, array $post)
{
/**
* @param Method $method HTTP method
* @param Headers $headers Request headers
* @param string $body Raw request body
* @param array<string, mixed> $parsedData Pre-parsed data (from parser)
*/
public function __construct(
Method $method,
Headers $headers,
string $body,
array $parsedData
) {
// For GET requests, data comes from query parameters (passed as parsedData)
if ($method === Method::GET) {
$this->data = $_GET;
$this->data = $parsedData;
return;
}
// For other methods, check if we have pre-parsed data
if (! empty($parsedData)) {
$this->data = $parsedData;
return;
}
// If no pre-parsed data, parse based on content type
$contentType = $headers->get(HeaderKey::CONTENT_TYPE);
// Prüfen, ob $contentType ein Array ist und konvertiere es ggf. in einen String
// Normalize content type
if (is_array($contentType)) {
$contentType = $contentType[0] ?? '';
} elseif ($contentType === null) {
$contentType = '';
}
// Parse based on content type
if (str_contains($contentType, MimeType::APPLICATION_JSON->value)) {
$parsedBody = json_decode($body, true) ?? [];
$this->data = json_decode($body, true) ?? [];
} elseif (str_contains($contentType, MimeType::APPLICATION_FORM->value)) {
parse_str($body, $parsedBody);
} elseif (str_contains($contentType, MimeType::MULTIPART_FORM_DATA->value)) {
// Bei multipart/form-data verwende $_POST
$parsedBody = $post;
$this->data = $parsedBody;
} else {
// Fallback
$parsedBody = [];
// For other content types or empty body
$this->data = [];
}
$this->data = $parsedBody;
}
/**
* Get a value from the parsed body
*
* @param string $key The key to retrieve
* @param mixed $default Default value if key doesn't exist
* @return mixed The value or default
*/
public function get(string $key, mixed $default = null): mixed
{
return $this->data[$key] ?? $default;
}
/**
* Check if a key exists in the parsed body
*
* @param string $key The key to check
* @return bool
*/
public function has(string $key): bool
{
return isset($this->data[$key]);
}
/**
* Get all parsed data
*
* @return array<string, mixed>
*/
public function all(): array
{
return $this->data;
}
/**
* Get a subset of the data by keys
*
* @param array<string> $keys Keys to include
* @return array<string, mixed>
*/
public function only(array $keys): array
{
return array_intersect_key($this->data, array_flip($keys));
}
/**
* Get all data except specified keys
*
* @param array<string> $keys Keys to exclude
* @return array<string, mixed>
*/
public function except(array $keys): array
{
return array_diff_key($this->data, array_flip($keys));
}
}

View File

@@ -5,98 +5,66 @@ declare(strict_types=1);
namespace App\Framework\Http;
use App\Framework\DI\Initializer;
use App\Framework\Http\Cookies\Cookie;
use App\Framework\Http\Cookies\Cookies;
use App\Framework\Router\Exception\RouteNotFound;
use App\Framework\Http\Parser\HttpRequestParser;
/**
* Erstellt HTTP-Requests aus den globalen Variablen
* Factory for creating HTTP requests
* Now uses the new parser system instead of direct superglobal access
*/
final readonly class RequestFactory
{
public function __construct(
private RequestIdGenerator $requestIdGenerator
) {}
private HttpRequestParser $parser
) {
}
/**
* Erstellt einen HTTP-Request aus den globalen Variablen
* Create a request from PHP globals
* This is the only place where we access superglobals
*
* @return Request
*/
#[Initializer]
public function createFromGlobals(): Request
{
$server = new ServerEnvironment($_SERVER);
// Get raw body once
$rawBody = file_get_contents('php://input') ?: '';
$originalRequestUri = (string)$server->getRequestUri();
$rawPath = parse_url($originalRequestUri, PHP_URL_PATH);
if ($rawPath === false) {
throw new RouteNotFound($originalRequestUri);
// Debug: Track request parsing
if (strlen($rawBody) === 0 && ! empty($_POST)) {
error_log("RequestFactory: Detected empty php://input with populated \$_POST");
}
if($rawPath !== '/') {
$rawPath = rtrim($rawPath, '/');
}
$headers = $this->getHeadersFromServer();
$cookies = $this->getCookiesFromServer();
$method = $server->getRequestMethod();
$body = file_get_contents('php://input') ?: '';
$parsedBody = new RequestBody($method, $headers, $body, $_POST);
if ($method === Method::POST) {
$_method = Method::tryFrom($parsedBody->get('_method', ''));
if($_method !== null) {
$method = $_method;
}
}
return new HttpRequest(
method: $method,
headers: $headers,
body: $body,
path: $rawPath,
queryParams: $_GET,
files: new UploadedFiles($_FILES),
cookies: $cookies,
server: $server,
id: $this->requestIdGenerator->generate(),
parsedBody: $parsedBody
);
// Use parser with minimal superglobal access
return $this->parser->parseFromGlobals($_SERVER, $rawBody);
}
/**
* Extrahiert HTTP-Header aus den Server-Variablen
* Create a request from raw components (for testing)
*
* @param string $method HTTP method
* @param string $uri Request URI
* @param array<string, mixed> $server Server data
* @param string $body Raw body
* @return Request
*/
private function getHeadersFromServer(): Headers
{
$headers = new Headers();
foreach ($_SERVER as $key => $value) {
// Prüfen auf HTTP_-Präfix (Standard-Header)
if (str_starts_with($key, 'HTTP_')) {
$headerName = str_replace('_', '-', substr($key, 5));
$headers = $headers->with($headerName, $value);
}
// Spezielle Header wie Content-Type und Content-Length haben kein HTTP_-Präfix
elseif (in_array($key, ['CONTENT_TYPE', 'CONTENT_LENGTH'])) {
$headerName = str_replace('_', '-', $key);
$headers = $headers->with($headerName, $value);
}
}
return $headers;
public function createFromComponents(
string $method,
string $uri,
array $server = [],
string $body = ''
): Request {
return $this->parser->parseRequest($method, $uri, $server, $body);
}
private function getCookiesFromServer(): Cookies
/**
* Create a request from a raw HTTP string (for testing)
*
* @param string $rawHttpRequest Raw HTTP request string
* @return Request
*/
public function createFromRawHttp(string $rawHttpRequest): Request
{
$cookies = [];
foreach ($_COOKIE as $key => $value) {
$cookies[] = new Cookie($key, $value);
}
return new Cookies(...$cookies);
return $this->parser->parseRawHttpRequest($rawHttpRequest);
}
}

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
@@ -10,7 +11,9 @@ namespace App\Framework\Http;
final readonly class RequestId
{
private string $id;
private string $signature;
private string $combined;
/**
@@ -31,7 +34,7 @@ final readonly class RequestId
// Signatur validieren
$expectedSignature = $this->generateSignature($rawId, $secret);
if (!hash_equals($expectedSignature, $signature)) {
if (! hash_equals($expectedSignature, $signature)) {
// Bei ungültiger Signatur, neue ID erstellen
$this->generateNew($secret);
} else {

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
@@ -15,6 +16,7 @@ final class RequestIdGenerator
private const string REQUEST_ID_HEADER = 'X-Request-ID';
private ?RequestId $requestId = null;
private string $secret;
/**

View File

@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
/**
* Helper class for manipulating HTTP requests without modifying the original request objects
*/
final readonly class RequestManipulator
{
/**
* Create a new request with an additional header
*/
public function withHeader(HttpRequest $request, string $name, string $value): HttpRequest
{
$newHeaders = $request->headers->with($name, $value);
return new HttpRequest(
method: $request->method,
headers: $newHeaders,
body: $request->body,
path: $request->path,
queryParams: $request->queryParams,
files: $request->files,
cookies: $request->cookies,
server: $request->server,
id: $request->id,
parsedBody: $request->parsedBody,
);
}
/**
* Create a new request with updated headers
*/
public function withHeaders(HttpRequest $request, Headers $headers): HttpRequest
{
return new HttpRequest(
method: $request->method,
headers: $headers,
body: $request->body,
path: $request->path,
queryParams: $request->queryParams,
files: $request->files,
cookies: $request->cookies,
server: $request->server,
id: $request->id,
parsedBody: $request->parsedBody,
);
}
/**
* Create a new request without a specific header
*/
public function withoutHeader(HttpRequest $request, string $name): HttpRequest
{
$newHeaders = $request->headers->without($name);
return new HttpRequest(
method: $request->method,
headers: $newHeaders,
body: $request->body,
path: $request->path,
queryParams: $request->queryParams,
files: $request->files,
cookies: $request->cookies,
server: $request->server,
id: $request->id,
parsedBody: $request->parsedBody,
);
}
/**
* Create a new request with multiple headers added
*/
public function withAddedHeaders(HttpRequest $request, array $headers): HttpRequest
{
$newHeaders = $request->headers;
foreach ($headers as $name => $value) {
$newHeaders = $newHeaders->with($name, $value);
}
return new HttpRequest(
method: $request->method,
headers: $newHeaders,
body: $request->body,
path: $request->path,
queryParams: $request->queryParams,
files: $request->files,
cookies: $request->cookies,
server: $request->server,
id: $request->id,
parsedBody: $request->parsedBody,
);
}
}

View File

@@ -1,41 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
use WeakMap;
final class RequestStateManager
{
/**
* @param WeakMap<object, mixed> $requestStates
*/
public function __construct(
private WeakMap $requestStates,
private readonly Request $request,
) {}
) {
}
public function namespace(string $namespace): NamespacedState
{
return new NamespacedState($this, $namespace);
}
public function set(string $key, mixed $value): void
public function set(StateKey|string $key, mixed $value): void
{
if(!isset($this->requestStates[$this->request])) {
$keyString = $key instanceof StateKey ? $key->value : $key;
if (! isset($this->requestStates[$this->request])) {
$this->requestStates[$this->request] = [];
}
$this->requestStates[$this->request][$key] = $value;
$this->requestStates[$this->request][$keyString] = $value;
}
public function get(string $key, mixed $default = null): mixed
public function get(StateKey|string $key, mixed $default = null): mixed
{
return $this->requestStates[$this->request][$key] ?? $default;
$keyString = $key instanceof StateKey ? $key->value : $key;
return $this->requestStates[$this->request][$keyString] ?? $default;
}
public function has(string $key): bool
public function has(StateKey|string $key): bool
{
return isset($this->requestStates[$this->request][$key]);
$keyString = $key instanceof StateKey ? $key->value : $key;
return isset($this->requestStates[$this->request][$keyString]);
}
public function remove(string $key): void
public function remove(StateKey|string $key): void
{
unset($this->requestStates[$this->request][$key]);
$keyString = $key instanceof StateKey ? $key->value : $key;
unset($this->requestStates[$this->request][$keyString]);
}
}

View File

@@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
/**
* Result of middleware dependency resolution with metadata
*/
final readonly class ResolvedMiddlewareStack
{
/**
* @param array<string> $middlewares
* @param array<string, mixed> $dependencyGraph
*/
public function __construct(
private array $middlewares,
private array $dependencyGraph
) {
}
/**
* Get middlewares in resolved execution order
* @return array<string>
*/
public function getMiddlewares(): array
{
return $this->middlewares;
}
/**
* Get dependency graph for debugging
* @return array<string, mixed>
*/
public function getDependencyGraph(): array
{
return $this->dependencyGraph;
}
/**
* Get count of resolved middlewares
*/
public function count(): int
{
return count($this->middlewares);
}
/**
* Check if a specific middleware was resolved
*/
public function has(string $middlewareClass): bool
{
return in_array($middlewareClass, $this->middlewares, true);
}
/**
* Get dependencies for a specific middleware
* @return array<string>
*/
public function getDependenciesFor(string $middlewareClass): array
{
return $this->dependencyGraph[$middlewareClass]['dependencies'] ?? [];
}
/**
* Get services that a middleware provides
* @return array<string>
*/
public function getProvidedBy(string $middlewareClass): array
{
return $this->dependencyGraph[$middlewareClass]['provides'] ?? [];
}
/**
* Check if a middleware is marked as critical
*/
public function isCritical(string $middlewareClass): bool
{
return $this->dependencyGraph[$middlewareClass]['is_critical'] ?? false;
}
/**
* Get all critical middlewares
* @return array<string>
*/
public function getCriticalMiddlewares(): array
{
$critical = [];
foreach ($this->middlewares as $middleware) {
if ($this->isCritical($middleware)) {
$critical[] = $middleware;
}
}
return $critical;
}
/**
* Get detailed resolution info for debugging
* @return array<string, mixed>
*/
public function getResolutionInfo(): array
{
return [
'total_middlewares' => count($this->middlewares),
'execution_order' => array_map(fn ($class) => basename($class), $this->middlewares),
'critical_middlewares' => array_map(fn ($class) => basename($class), $this->getCriticalMiddlewares()),
'dependency_chains' => $this->getDependencyChains(),
'circular_dependencies' => $this->detectCircularDependencies(),
];
}
/**
* Get dependency chains for visualization
* @return array<string, mixed>
*/
private function getDependencyChains(): array
{
$chains = [];
foreach ($this->middlewares as $middleware) {
$dependencies = $this->getDependenciesFor($middleware);
if (! empty($dependencies)) {
$chains[basename($middleware)] = array_map(fn ($dep) => basename($dep), $dependencies);
}
}
return $chains;
}
/**
* Detect any circular dependencies in the graph
* @return array<string>
*/
private function detectCircularDependencies(): array
{
$circular = [];
$visited = [];
foreach (array_keys($this->dependencyGraph) as $node) {
if (! isset($visited[$node])) {
$result = $this->detectCircularDfs($node, $visited, []);
if ($result['hasCircular']) {
$circular = array_merge($circular, $result['circular']);
}
$visited = array_merge($visited, $result['visited']);
}
}
return $circular;
}
/**
* DFS for circular dependency detection (returns result instead of using references)
* @param array<string, bool> $visited
* @param array<string, bool> $stack
* @return array<string, mixed>
*/
private function detectCircularDfs(string $node, array $visited, array $stack): array
{
$visited[$node] = true;
$stack[$node] = true;
$circular = [];
$dependencies = $this->dependencyGraph[$node]['dependencies'] ?? [];
foreach ($dependencies as $dependency) {
if (! isset($visited[$dependency])) {
$result = $this->detectCircularDfs($dependency, $visited, $stack);
$visited = array_merge($visited, $result['visited']);
$circular = array_merge($circular, $result['circular']);
} elseif (isset($stack[$dependency])) {
$circular[] = [$node, $dependency];
}
}
unset($stack[$node]);
return [
'visited' => $visited,
'circular' => $circular,
'hasCircular' => ! empty($circular),
];
}
/**
* String representation for debugging
*/
public function __toString(): string
{
$middlewareNames = array_map(fn ($class) => basename($class), $this->middlewares);
return sprintf(
'ResolvedMiddlewareStack[%d middlewares: %s]',
count($this->middlewares),
implode(' → ', $middlewareNames)
);
}
}

View File

@@ -4,15 +4,20 @@ declare(strict_types=1);
namespace App\Framework\Http;
use App\Framework\DateTime\SystemTimer;
use App\Framework\DateTime\Timer;
use App\Framework\Http\Emitter\AdaptiveStreamEmitter;
use App\Framework\Http\Emitter\HttpEmitter;
use App\Framework\Http\Emitter\SseEmitter;
use App\Framework\Http\Emitter\StreamEmitter;
use App\Framework\Http\Emitter\WebSocketEmitter;
use App\Framework\Http\Emitter\AdaptiveStreamEmitter;
final readonly class ResponseEmitter
{
public function __construct() {}
public function __construct(
private readonly ?Timer $timer = null
) {
}
public function emit(Response $response, int $chunkSize = 8192): void
{
@@ -51,7 +56,7 @@ final readonly class ResponseEmitter
*/
private function emitSseResponse(Response $response): void
{
new SseEmitter()->emit($response);
new SseEmitter($this->timer ?? new SystemTimer())->emit($response);
}
/**

View File

@@ -1,8 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
use App\Framework\Http\Responses\SseResponse;
use App\Framework\Http\Responses\Streamable;
final readonly class ResponseManipulator
@@ -21,7 +22,7 @@ final readonly class ResponseManipulator
);
}
public function withHeader(Response $response, string $name, string $value): Response
public function withHeader(Response $response, HeaderKey|string $name, string $value): Response
{
// Spezielle Response-Typen erhalten
if ($response instanceof Streamable) {
@@ -30,6 +31,7 @@ final readonly class ResponseManipulator
$headers = clone $response->headers;
$headers = $headers->with($name, $value);
return new HttpResponse(
status: $response->status,
headers: $headers,

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;

View File

@@ -1,12 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
use App\Framework\Http\Responses\AdaptiveStreamResponse;
use App\Framework\Http\Responses\SseResponse;
use App\Framework\Http\Responses\StreamResponse;
use App\Framework\Http\Responses\WebSocketResponse;
use App\Framework\Http\Responses\AdaptiveStreamResponse;
use App\Framework\Http\Streaming\MimeTypeDetector;
use App\Framework\Http\Streaming\StreamingFormat;
@@ -14,22 +15,22 @@ final readonly class ResponseTypeDetector
{
public function detect(Response $response): ResponseType
{
if($response instanceof SseResponse) {
if ($response instanceof SseResponse) {
return ResponseType::SSE;
}
if($response instanceof WebsocketResponse) {
if ($response instanceof WebsocketResponse) {
return ResponseType::WEBSOCKET;
}
if($response instanceof AdaptiveStreamResponse) {
if ($response instanceof AdaptiveStreamResponse) {
return match ($response->format) {
StreamingFormat::HLS => ResponseType::HLS,
StreamingFormat::DASH => ResponseType::DASH
};
}
if($response instanceof StreamResponse) {
if ($response instanceof StreamResponse) {
return ResponseType::MEDIA_STREAM;
}
@@ -38,11 +39,11 @@ final readonly class ResponseTypeDetector
private function detectFromHeaders(Headers $headers): ResponseType
{
if($this->isSseResponse($headers)) {
if ($this->isSseResponse($headers)) {
return ResponseType::SSE;
}
if($this->isWebSocketResponse($headers)) {
if ($this->isWebSocketResponse($headers)) {
return ResponseType::WEBSOCKET;
}
@@ -60,7 +61,7 @@ final readonly class ResponseTypeDetector
return ResponseType::DASH;
}
if(MimeTypeDetector::isStreamable($contentType ?? '')) {
if (MimeTypeDetector::isStreamable($contentType ?? '')) {
return ResponseType::MEDIA_STREAM;
}
@@ -82,5 +83,4 @@ final readonly class ResponseTypeDetector
return $connection === 'upgrade' && $upgrade === 'websocket';
}
}

View File

@@ -5,9 +5,9 @@ declare(strict_types=1);
namespace App\Framework\Http\Responses;
use App\Framework\Http\HeaderKey;
use App\Framework\Http\Headers;
use App\Framework\Http\Response;
use App\Framework\Http\Status;
use App\Framework\Http\Headers;
use App\Framework\Http\Streaming\AdaptivePlaylist;
use App\Framework\Http\Streaming\StreamingFormat;
@@ -21,16 +21,15 @@ final readonly class AdaptiveStreamResponse implements Response, Streamable
public AdaptivePlaylist $playlist,
public Status $status = Status::OK,
Headers $headers = new Headers(),
public StreamingFormat $format = StreamingFormat::HLS
) {
$this->body = $this->playlist->generate($this->format);
$this->headers = $headers
->with(HeaderKey::CACHE_CONTROL, 'max-age=60', )
->with(HeaderKey::ACCESS_CONTROL_ALLOW_ORIGIN, '*' )
->with(HeaderKey::ACCESS_CONTROL_ALLOW_ORIGIN, '*')
->with(HeaderKey::ACCESS_CONTROL_ALLOW_METHODS, 'GET, HEAD, OPTIONS')
->with(HeaderKey::ACCESS_CONTROL_ALLOW_HEADERS, 'RANGE' );
->with(HeaderKey::ACCESS_CONTROL_ALLOW_HEADERS, 'RANGE');
// Format-spezifische Header setzen
#$this->headers = $this->headers->with('Content-Type', $this->format->getContentType());

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Responses;
@@ -12,6 +13,7 @@ final readonly class JsonResponse implements Response
public Headers $headers;
public string $body;
public function __construct(
array $body = [],
public Status $status = Status::OK,

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Responses;
@@ -9,7 +10,6 @@ enum MediaType: string
case AUDIO = 'audio';
case UNKNOWN = 'unknown';
public static function fromMimeType(string $mimeType): self
{
return match (true) {

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Responses;
use App\Framework\Http\Headers;
@@ -9,6 +11,7 @@ use App\Framework\Http\Status;
final readonly class NotFound implements Response
{
public Status $status;
public Headers $headers;
public function __construct(

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