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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
50
src/Framework/Http/Batch/BatchConfig.php
Normal file
50
src/Framework/Http/Batch/BatchConfig.php
Normal 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);
|
||||
}
|
||||
}
|
||||
47
src/Framework/Http/Batch/BatchOperation.php
Normal file
47
src/Framework/Http/Batch/BatchOperation.php
Normal 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'] ?? []
|
||||
);
|
||||
}
|
||||
}
|
||||
219
src/Framework/Http/Batch/BatchProcessor.php
Normal file
219
src/Framework/Http/Batch/BatchProcessor.php
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
113
src/Framework/Http/Batch/BatchRequest.php
Normal file
113
src/Framework/Http/Batch/BatchRequest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
61
src/Framework/Http/Batch/BatchResponse.php
Normal file
61
src/Framework/Http/Batch/BatchResponse.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http;
|
||||
|
||||
interface ControllerRequest
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http\Cookies;
|
||||
|
||||
enum SameSite: string
|
||||
|
||||
70
src/Framework/Http/ETagInitializer.php
Normal file
70
src/Framework/Http/ETagInitializer.php
Normal 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));
|
||||
}
|
||||
}
|
||||
261
src/Framework/Http/ETagManager.php
Normal file
261
src/Framework/Http/ETagManager.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http\Emitter;
|
||||
|
||||
use App\Framework\Http\Response;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http\Exception;
|
||||
|
||||
use App\Framework\Http\Status;
|
||||
|
||||
@@ -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;
|
||||
|
||||
36
src/Framework/Http/Exceptions/MiddlewareTimeoutException.php
Normal file
36
src/Framework/Http/Exceptions/MiddlewareTimeoutException.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http;
|
||||
|
||||
interface HttpMiddlewareNext
|
||||
interface HttpMiddlewareChainInterface
|
||||
{
|
||||
public function handle(HttpRequest $request): Response;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,5 +10,6 @@ final readonly class HttpResponse implements Response
|
||||
public Status $status = Status::OK,
|
||||
public Headers $headers = new Headers(),
|
||||
public string $body = ''
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
128
src/Framework/Http/Metrics/MiddlewareMetrics.php
Normal file
128
src/Framework/Http/Metrics/MiddlewareMetrics.php
Normal 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);
|
||||
}
|
||||
}
|
||||
145
src/Framework/Http/Metrics/MiddlewareMetricsCollector.php
Normal file
145
src/Framework/Http/Metrics/MiddlewareMetricsCollector.php
Normal 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);
|
||||
}
|
||||
}
|
||||
214
src/Framework/Http/Middleware/ETagMiddleware.php
Normal file
214
src/Framework/Http/Middleware/ETagMiddleware.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
143
src/Framework/Http/MiddlewareCircuitBreaker.php
Normal file
143
src/Framework/Http/MiddlewareCircuitBreaker.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
121
src/Framework/Http/MiddlewareDependencyException.php
Normal file
121
src/Framework/Http/MiddlewareDependencyException.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
659
src/Framework/Http/MiddlewareDependencyResolver.php
Normal file
659
src/Framework/Http/MiddlewareDependencyResolver.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
13
src/Framework/Http/MiddlewareManagerInterface.php
Normal file
13
src/Framework/Http/MiddlewareManagerInterface.php
Normal 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;}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
130
src/Framework/Http/Middlewares/ApiVersioningMiddleware.php
Normal file
130
src/Framework/Http/Middlewares/ApiVersioningMiddleware.php
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
210
src/Framework/Http/Middlewares/DDoSProtectionMiddleware.php
Normal file
210
src/Framework/Http/Middlewares/DDoSProtectionMiddleware.php
Normal 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);
|
||||
}
|
||||
}
|
||||
45
src/Framework/Http/Middlewares/DefaultResponseMiddleware.php
Normal file
45
src/Framework/Http/Middlewares/DefaultResponseMiddleware.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
326
src/Framework/Http/Middlewares/FormDataResponseMiddleware.php
Normal file
326
src/Framework/Http/Middlewares/FormDataResponseMiddleware.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
144
src/Framework/Http/Middlewares/REFACTORING-SUMMARY.md
Normal file
144
src/Framework/Http/Middlewares/REFACTORING-SUMMARY.md
Normal 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.
|
||||
161
src/Framework/Http/Middlewares/RateLimitMiddleware.php
Normal file
161
src/Framework/Http/Middlewares/RateLimitMiddleware.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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, '/');
|
||||
247
src/Framework/Http/Middlewares/WafMiddleware.php
Normal file
247
src/Framework/Http/Middlewares/WafMiddleware.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
97
src/Framework/Http/MimeTypeAnalyzer.php
Normal file
97
src/Framework/Http/MimeTypeAnalyzer.php
Normal 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);
|
||||
}
|
||||
}
|
||||
152
src/Framework/Http/MimeTypeResolver.php
Normal file
152
src/Framework/Http/MimeTypeResolver.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
20
src/Framework/Http/Next.php
Normal file
20
src/Framework/Http/Next.php
Normal 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;
|
||||
}
|
||||
308
src/Framework/Http/Parser/CookieParser.php
Normal file
308
src/Framework/Http/Parser/CookieParser.php
Normal 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}'"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
179
src/Framework/Http/Parser/Exception/ParserSecurityException.php
Normal file
179
src/Framework/Http/Parser/Exception/ParserSecurityException.php
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
522
src/Framework/Http/Parser/FileUploadParser.php
Normal file
522
src/Framework/Http/Parser/FileUploadParser.php
Normal 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;
|
||||
}
|
||||
}
|
||||
358
src/Framework/Http/Parser/FormDataParser.php
Normal file
358
src/Framework/Http/Parser/FormDataParser.php
Normal 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}'"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
355
src/Framework/Http/Parser/HeaderParser.php
Normal file
355
src/Framework/Http/Parser/HeaderParser.php
Normal 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}'"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
511
src/Framework/Http/Parser/HttpRequestParser.php
Normal file
511
src/Framework/Http/Parser/HttpRequestParser.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
298
src/Framework/Http/Parser/ParserCache.php
Normal file
298
src/Framework/Http/Parser/ParserCache.php
Normal 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));
|
||||
|
||||
}
|
||||
}
|
||||
209
src/Framework/Http/Parser/ParserConfig.php
Normal file
209
src/Framework/Http/Parser/ParserConfig.php
Normal 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();
|
||||
}
|
||||
}
|
||||
181
src/Framework/Http/Parser/QueryStringParser.php
Normal file
181
src/Framework/Http/Parser/QueryStringParser.php
Normal 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;
|
||||
}
|
||||
}
|
||||
344
src/Framework/Http/Parser/StreamingParser.php
Normal file
344
src/Framework/Http/Parser/StreamingParser.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
28
src/Framework/Http/Query.php
Normal file
28
src/Framework/Http/Query.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
97
src/Framework/Http/RequestManipulator.php
Normal file
97
src/Framework/Http/RequestManipulator.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
200
src/Framework/Http/ResolvedMiddlewareStack.php
Normal file
200
src/Framework/Http/ResolvedMiddlewareStack.php
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http;
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user