chore: complete update

This commit is contained in:
2025-07-17 16:24:20 +02:00
parent 899227b0a4
commit 64a7051137
1300 changed files with 85570 additions and 2756 deletions

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Api;
use App\Framework\Attributes\Route;
use App\Framework\Http\Method;
use App\Framework\Http\Status;
use App\Framework\Logging\DefaultLogger;
use App\Framework\Logging\LogLevel;
use App\Framework\Router\Result\JsonResult;
/**
* API-Controller für Client-Logging.
* Ermöglicht es dem Frontend, Logs an den Server zu senden und dabei die Request-ID zu verwenden.
*/
final readonly class ClientLogController
{
public function __construct(
private DefaultLogger $logger
) {}
#[Route(path: '/api/log', method: Method::POST)]
public function __invoke(ClientLogRequest $request): JsonResult
{
// Log-Level aus dem Client konvertieren
$level = match ($request->level) {
'error' => LogLevel::ERROR,
'warn' => LogLevel::WARNING,
'info' => LogLevel::INFO,
default => LogLevel::DEBUG,
};
// Log mit Kontext schreiben
$this->logger->log($level, "Client: {$request->message}", [
'client' => true,
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown',
'context' => $request->context ?? [],
]);
return new JsonResult([
'success' => true,
], Status::OK);
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Framework\Http\Api;
use App\Framework\Http\ControllerRequest;
/**
* Request-DTO für den Client-Log-Endpunkt
*/
final readonly class ClientLogRequest implements ControllerRequest
{
public function __construct(
public string $level,
public string $message,
public ?array $context = null,
) {}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Framework\Http;
interface ControllerRequest
{
}

View File

@@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
class Cookies
{
/** @var array<string, Cookie> */
private array $cookies = [];
public function __construct(array $rawCookies = [])
{
}
}

View File

@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Framework\Http;
namespace App\Framework\Http\Cookies;
final readonly class Cookie
{
@@ -22,7 +22,7 @@ final readonly class Cookie
{
$cookie = urlencode($this->name) . '=' . urlencode($this->value);
if ($this->expires !== null) {
if ($this->expires !== null && $this->expires > 0) {
$cookie .= '; Expires=' . gmdate('D, d-M-Y H:i:s T', $this->expires);
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Cookies;
final readonly class Cookies
{
/** @var array<string, Cookie> */
private array $cookies;
public function __construct(Cookie ...$cookies)
{
foreach ($cookies as $cookie) {
$cookies[$cookie->name] = $cookie;
}
$this->cookies = $cookies;
}
public function get(string $name): ?Cookie
{
return $this->cookies[$name] ?? null;
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Framework\Http\Cookies;
enum SameSite: string
{
case Strict = 'Strict';
case Lax = 'Lax';
case None = 'None';
}

View File

@@ -0,0 +1,92 @@
# HTTP-Modul Dokumentation
## Übersicht
Das HTTP-Modul stellt Komponenten für die Verarbeitung von HTTP-Anfragen und -Antworten bereit.
## Hauptkomponenten
### Request
Repräsentiert eine HTTP-Anfrage mit Methoden zum Zugriff auf:
- HTTP-Methode (`getMethod()`)
- Pfad (`getPath()`)
- Query-Parameter (`getQueryParams()`)
- Request-Body
- Headers
### Response
Repräsentiert eine HTTP-Antwort mit:
- Status-Code (`getStatusCode()`)
- Headers
- Body
### Middleware
Ein Interface für HTTP-Middleware-Komponenten, die die Request-Verarbeitung verändern können:
```php
interface Middleware
{
public function process(Request $request, callable $next): Response;
}
```
**Beispiel-Middleware:**
```php
class AnalyticsMiddleware implements Middleware
{
public function __construct(
private readonly Analytics $analytics
) {}
public function process(Request $request, callable $next): Response
{
// Request tracken
$this->analytics->track('http_request', [
'method' => $request->getMethod(),
'path' => $request->getPath(),
]);
// Request weiterleiten
$response = $next($request);
// Response tracken
$this->analytics->track('http_response', [
'status_code' => $response->getStatusCode(),
]);
return $response;
}
}
```
## Integration mit anderen Modulen
- **Router**: Routing von HTTP-Requests zu Controllers
- **Analytics**: Tracking von HTTP-Requests und -Responses
- **Validation**: Validierung von Request-Daten
## Middlewares registrieren
In der Anwendungsklasse:
```php
$this->addMiddleware(LoggingMiddleware::class);
$this->addMiddleware(AnalyticsMiddleware::class);
$this->addMiddleware(AuthenticationMiddleware::class);
```
## Responses erzeugen
```php
// JSON-Response
return new JsonResponse(['success' => true]);
// HTML-Response
return new HtmlResponse('<html><body>Hello World</body></html>');
// Redirect
return new RedirectResponse('/dashboard');
```

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Emitter;
use App\Framework\Http\Response;
use App\Framework\Http\Responses\AdaptiveStreamResponse;
final class AdaptiveStreamEmitter implements Emitter
{
public function emit(Response $response): void
{
if (!$response instanceof AdaptiveStreamResponse) {
throw new \InvalidArgumentException('Response must be AdaptiveStreamResponse');
}
// HTTP Status senden
http_response_code($response->status->value);
// Header senden
foreach ($response->headers->all() as $name => $values) {
foreach ($values as $value) {
header("{$name}: {$value}");
}
}
// Playlist/Manifest ausgeben
echo $response->body;
if (ob_get_level() > 0) {
ob_flush();
}
flush();
}
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Framework\Http\Emitter;
use App\Framework\Http\Response;
interface Emitter
{
public function emit(Response $response): void;
}

View File

@@ -0,0 +1,46 @@
<?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.");
}
// 2. Robustheit: Jeglichen zuvor gepufferten Output löschen, um unbeabsichtigte Ausgaben zu vermeiden.
if (ob_get_level() > 0 && ob_get_length() > 0) {
ob_clean();
}
// Status-Code senden
http_response_code($response->status->value);
// 4. Header senden (korrigierte und vereinfachte Logik)
foreach ($response->headers->all() as $name => $value) {
// Der `Set-Cookie`-Header ist ein Sonderfall und darf nicht kombiniert werden.
if (strtolower($name) === 'set-cookie') {
foreach ((array)$value as $cookie) {
header("Set-Cookie: $cookie", false);
}
// Wichtig: Mit dem nächsten Header fortfahren, um doppeltes Senden zu verhindern.
continue;
}
// Alle anderen Header mit mehreren Werten können kombiniert werden.
$headerValue = is_array($value) ? implode(', ', $value) : $value;
header("$name: $headerValue", true);
}
// Body ausgeben
echo $response->body;
}
}

View File

@@ -0,0 +1,71 @@
<?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\SseResponse;
use App\Framework\Http\SseStream;
final readonly class SseEmitter implements Emitter
{
public function emit(Response $response): void
{
if($response instanceof SseResponse === false) {
throw new \InvalidArgumentException('Response must be an instance of SseResponse');
}
// Stream-Objekt erstellen
$stream = new SseStream();
// Stream starten (sendet Header und initiale Events)
$stream->start(
$response->headers,
$response->status,
$response->initialEvents
);
// Streaming-Parameter
$startTime = time();
$lastHeartbeat = $startTime;
$heartbeatInterval = $response->heartbeatInterval;
$maxDuration = $response->maxDuration;
$callback = $response->streamCallback;
// Streaming-Loop
while ($stream->isConnectionActive()) {
// Prüfen, ob die maximale Dauer erreicht wurde
if ($maxDuration > 0 && (time() - $startTime) >= $maxDuration) {
$stream->sendJson(['message' => 'Maximum duration reached'], 'timeout');
break;
}
// Callback ausführen, falls vorhanden
if ($callback !== null) {
try {
call_user_func($callback, $stream);
} catch (\Throwable $e) {
// Fehler im Callback abfangen
$stream->sendJson([
'error' => $e->getMessage(),
'type' => get_class($e)
], 'error');
break;
}
}
// Heartbeat senden
if (time() - $lastHeartbeat >= $heartbeatInterval) {
$stream->sendHeartbeat();
$lastHeartbeat = time();
}
// Kurze Pause, um CPU-Last zu reduzieren
usleep(100_000); // 100ms
}
// Stream schließen
$stream->close();
}
}

View File

@@ -0,0 +1,109 @@
<?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;
use App\Framework\Http\Status;
final class StreamEmitter implements Emitter
{
public function emit(Response $response): void
{
if (!$response instanceof StreamResponse) {
throw new \InvalidArgumentException('Response must be StreamResponse');
}
$chunkSize = 8192;
// Range Request verarbeiten
if ($response->range !== null) {
$this->emitRangeResponse($response, $chunkSize);
} else {
$this->emitFullResponse($response, $chunkSize);
}
}
private function emitRangeResponse(StreamResponse $response, int $chunkSize): void
{
$range = $response->range;
// 206 Partial Content Status
http_response_code(Status::PARTIAL_CONTENT->value);
// Range-spezifische Header
header("Content-Range: bytes {$range->start}-{$range->end}/{$range->total}");
header('Content-Length: ' . $range->length);
header("Content-Type: {$response->mimeType}");
header('Accept-Ranges: bytes');
// Zusätzliche Header
foreach ($response->headers->all() as $name => $value) {
$headerValue = is_array($value) ? implode(', ', $value) : $value;
header("$name: $headerValue");
}
$this->streamRange($response->filePath, $range, $chunkSize);
}
private function streamRange(string $filePath,Range $range, int $chunkSize): void
{
// Stream öffnen und zur Start-Position springen
$handle = fopen($filePath, 'rb');
fseek($handle, $range->start);
$remaining = $range->length;
while ($remaining > 0 && !feof($handle)) {
$readSize = min($chunkSize, $remaining);
$chunk = fread($handle, $readSize);
echo $chunk;
$remaining -= strlen($chunk);
// Output buffer leeren
$this->flushOutput();
}
fclose($handle);
}
private function flushOutput(): void
{
if (ob_get_level() > 0) {
ob_flush();
}
flush();
}
private function emitFullResponse(StreamResponse $response, int $chunkSize): void
{
// Standard Header
http_response_code($response->status->value);
header("Content-Type: {$response->mimeType}");
header("Content-Length: {$response->fileSize}");
header('Accept-Ranges: bytes');
$this->streamFile($response->filePath, $chunkSize);
}
private function streamFile(string $filePath, int $chunkSize): void
{
// Stream komplett senden
$handle = fopen($filePath, 'rb');
while (!feof($handle)) {
echo fread($handle, $chunkSize);
$this->flushOutput();
}
fclose($handle);
}
}

View File

@@ -0,0 +1,48 @@
<?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;
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) {
throw new \InvalidArgumentException('Response must be an instance of WebSocketResponse');
}
// 1. Robustheit: Prüfen, ob Header bereits gesendet wurden.
if (headers_sent($file, $line)) {
throw new \RuntimeException("Kann die WebSocket-Antwort nicht senden: Header wurden bereits in {$file}:{$line} gesendet.");
}
// 2. Output-Buffer leeren
if (ob_get_level() > 0 && ob_get_length() > 0) {
ob_clean();
}
// 3. Status-Code senden
http_response_code($response->status->value);
// 4. Header senden
foreach ($response->headers->toArray() as $name => $value) {
$headerValue = is_array($value) ? implode(', ', $value) : $value;
header("$name: $headerValue", true);
}
// 5. Output-Buffer leeren und alle Header senden
flush();
// 6. WebSocket-Server mit der Verbindung beginnen
$this->webSocketServer->handleUpgrade($response);
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Framework\Http\Exception;
use App\Framework\Http\Status;
interface HttpException
{
public Status $status {
get;
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Framework\Http\Exception;
use App\Framework\Exception\FrameworkException;
use App\Framework\Http\Status;
final class NotFound extends FrameworkException implements HttpException
{
public \App\Framework\Http\Status $status {
get {
return Status::NOT_FOUND;
}
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
/**
* Enum representing common HTTP header keys.
*
* Provides a predefined set of constants for HTTP headers used in request and response contexts.
* Each constant represents a standard HTTP header name as a string.
*/
namespace App\Framework\Http;
enum HeaderKey: string
{
case ACCEPT = 'Accept';
case ACCEPT_CHARSET = 'Accept-Charset';
case ACCEPT_ENCODING = 'Accept-Encoding';
case ACCEPT_LANGUAGE = 'Accept-Language';
case ACCEPT_RANGES = 'Accept-Ranges';
case ACCESS_CONTROL_ALLOW_CREDENTIALS = 'Access-Control-Allow-Credentials';
case ACCESS_CONTROL_ALLOW_HEADERS = 'Access-Control-Allow-Headers';
case ACCESS_CONTROL_ALLOW_METHODS = 'Access-Control-Allow-Methods';
case ACCESS_CONTROL_ALLOW_ORIGIN = 'Access-Control-Allow-Origin';
case ACCESS_CONTROL_EXPOSE_HEADERS = 'Access-Control-Expose-Headers';
case ACCESS_CONTROL_MAX_AGE = 'Access-Control-Max-Age';
case ACCESS_CONTROL_REQUEST_HEADERS = 'Access-Control-Request-Headers';
case ACCESS_CONTROL_REQUEST_METHOD = 'Access-Control-Request-Method';
case AGE = 'Age';
case ALLOW = 'Allow';
case AUTHORIZATION = 'Authorization';
case CACHE_CONTROL = 'Cache-Control';
case CONNECTION = 'Connection';
case CONTENT_DISPOSITION = 'Content-Disposition';
case CONTENT_ENCODING = 'Content-Encoding';
case CONTENT_LANGUAGE = 'Content-Language';
case CONTENT_LENGTH = 'Content-Length';
case CONTENT_LOCATION = 'Content-Location';
case CONTENT_RANGE = 'Content-Range';
case CONTENT_SECURITY_POLICY = 'Content-Security-Policy';
case CONTENT_TYPE = 'Content-Type';
case COOKIE = 'Cookie';
case DATE = 'Date';
case ETAG = 'ETag';
case EXPECT = 'Expect';
case EXPIRES = 'Expires';
case FORWARDED = 'Forwarded';
case FROM = 'From';
case HOST = 'Host';
case IF_MATCH = 'If-Match';
case IF_MODIFIED_SINCE = 'If-Modified-Since';
case IF_NONE_MATCH = 'If-None-Match';
case IF_RANGE = 'If-Range';
case IF_UNMODIFIED_SINCE = 'If-Unmodified-Since';
case LAST_MODIFIED = 'Last-Modified';
case LINK = 'Link';
case LOCATION = 'Location';
case MAX_FORWARDS = 'Max-Forwards';
case ORIGIN = 'Origin';
case PRAGMA = 'Pragma';
case PROXY_AUTHENTICATE = 'Proxy-Authenticate';
case PROXY_AUTHORIZATION = 'Proxy-Authorization';
case RANGE = 'Range';
case REFERER = 'Referer';
case RETRY_AFTER = 'Retry-After';
case SERVER = 'Server';
case SET_COOKIE = 'Set-Cookie';
case STRICT_TRANSPORT_SECURITY = 'Strict-Transport-Security';
case TE = 'TE';
case TRAILER = 'Trailer';
case TRANSFER_ENCODING = 'Transfer-Encoding';
case UPGRADE = 'Upgrade';
case USER_AGENT = 'User-Agent';
case VARY = 'Vary';
case VIA = 'Via';
case WARNING = 'Warning';
case WWW_AUTHENTICATE = 'WWW-Authenticate';
case X_FORWARDED_FOR = 'X-Forwarded-For';
case X_FORWARDED_HOST = 'X-Forwarded-Host';
case X_FORWARDED_PROTO = 'X-Forwarded-Proto';
case X_REQUESTED_WITH = 'X-Requested-With';
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Framework\Http;
/**
* Hilfsklasse für erweiterte Header-Funktionen ohne Modifikation der Headers-Klasse
*/
final readonly class HeaderManipulator
{
/**
* Konvertiert einen Header-String in ein Headers-Objekt
*/
public static function fromString(string $headersRaw): Headers
{
$headers = new Headers();
// Zeilenumbrüche vereinheitlichen und in Zeilen aufteilen
$lines = preg_split('/\r\n|\n|\r/', $headersRaw);
// Die erste Zeile kann die Status-Zeile sein, die überspringen wir
$statusLineFound = false;
foreach ($lines as $line) {
// Leere Zeilen überspringen
if (trim($line) === '') {
continue;
}
// Status-Zeile (HTTP/1.1 200 OK) überspringen
if (!$statusLineFound && preg_match('/^HTTP\/\d\.\d\s+\d+/', $line)) {
$statusLineFound = true;
continue;
}
// Header im Format "Name: Wert" parsen
if (preg_match('/^([^:]+):\s*(.*)$/', $line, $matches)) {
$name = $matches[1];
$value = $matches[2];
$headers = $headers->withAdded($name, $value);
}
}
return $headers;
}
/**
* Formatiert Headers für cURL
*/
public static function formatForCurl(Headers $headers): array
{
$formattedHeaders = [];
foreach ($headers->all() as $name => $values) {
foreach ($values as $value) {
$formattedHeaders[] = "$name: $value";
}
}
return $formattedHeaders;
}
}

View File

@@ -2,7 +2,9 @@
namespace App\Framework\Http;
final class Headers
use InvalidArgumentException;
final readonly class Headers
{
/**
* Struktur:
@@ -16,12 +18,18 @@ final class Headers
* @var array<string, array{string, string[]}>
* Struktur: 'normalized-lower-name' => [Original-Name, [Wert1, Wert2, ...]]
*/
public function __construct(
private readonly array $headers = []
) {}
private array $headers;
public function with(string $name, string|array $value): self
public function __construct(
array $headers = []
) {
$this->headers = $this->parseFromArray($headers);
}
public function with(HeaderKey|string $name, string|array $value): self
{
$name = $this->getNameAsString($name);
$key = strtolower($name);
$original = $this->normalizeName($name);
$values = is_array($value) ? array_values($value) : [$value];
@@ -32,8 +40,10 @@ final class Headers
return new self($new);
}
public function withAdded(string $name, string $value): self
public function withAdded(HeaderKey|string $name, string $value): self
{
$name = $this->getNameAsString($name);
$key = strtolower($name);
$original = $this->normalizeName($name);
@@ -47,8 +57,10 @@ final class Headers
return new self($new);
}
public function without(string $name): self
public function without(HeaderKey|string $name): self
{
$name = $this->getNameAsString($name);
$key = strtolower($name);
$new = $this->headers;
unset($new[$key]);
@@ -56,18 +68,22 @@ final class Headers
return new self($new);
}
public function get(string $name): ?array
public function get(HeaderKey|string $name): ?array
{
$name = $this->getNameAsString($name);
return $this->headers[strtolower($name)][1] ?? null;
}
public function getFirst(string $name): ?string
public function getFirst(HeaderKey|string $name): ?string
{
return $this->get($name)[0] ?? null;
}
public function has(string $name): bool
public function has(HeaderKey|string $name): bool
{
$name = $this->getNameAsString($name);
return isset($this->headers[strtolower($name)]);
}
@@ -83,8 +99,92 @@ final class Headers
return $output;
}
/**
* Normalizes the given header name by converting it to a canonical format.
* Each word in the name is capitalized, and hyphens are preserved.
*
* @param string $name The header name to be normalized.
* @return string The normalized header name.
*/
private function normalizeName(string $name): string
{
return preg_replace_callback('/(?:^|-)[a-z]/', fn($m) => strtoupper($m[0]), strtolower($name));
}
public function toArray(): array
{
return $this->headers;
}
private function getNameAsString(HeaderKey|string $name): string
{
return is_string($name) ? $name : $name->value;
}
/**
* Parses an array into the internal headers format.
* Handles both external format ['Header-Name' => 'value'] and internal format ['normalized-key' => ['Original-Name', ['values']]]
*
* @param array $headers Input array in either format
* @return array Internal headers format: ['normalized-key' => ['Original-Name', ['value1', 'value2']]]
*/
private function parseFromArray(array $headers): array
{
if (empty($headers)) {
return [];
}
// Check if this is already in internal format
if ($this->isInternalFormat($headers)) {
return $headers;
}
// Parse external format
$parsed = [];
foreach ($headers as $name => $value) {
if (!is_string($name)) {
throw new InvalidArgumentException('Header name must be a string');
}
$key = strtolower($name);
$original = $this->normalizeName($name);
if (is_string($value)) {
$parsed[$key] = [$original, [$value]];
} elseif (is_array($value)) {
// Alle Werte als Strings validieren
foreach ($value as $val) {
if (!is_string($val)) {
throw new InvalidArgumentException('All header values must be strings: ' . print_r($headers, true));
}
}
$parsed[$key] = [$original, array_values($value)];
} else {
throw new InvalidArgumentException('Header value must be a string or array of strings');
}
}
return $parsed;
}
/**
* Checks if the array is already in internal format
* Internal format: ['normalized-key' => ['Original-Name', ['value1', 'value2']]]
*/
private function isInternalFormat(array $headers): bool
{
if (empty($headers)) {
return true;
}
// Check the first entry to determine the format
$firstValue = reset($headers);
// Internal format has array values with exactly 2 elements: [string, array]
return is_array($firstValue) &&
count($firstValue) === 2 &&
is_string($firstValue[0]) &&
is_array($firstValue[1]);
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Framework\Http;
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 RequestStateManager $stateManager
* @return MiddlewareContext Der aktualisierte Kontext
*/
public function __invoke(MiddlewareContext $context, callable $next, RequestStateManager $stateManager): MiddlewareContext;
}

View File

@@ -0,0 +1,181 @@
<?php
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
{
private MiddlewareInvoker $invoker;
private DefaultLogger $logger;
private RequestStateManager $stateManager;
public function __construct(
private array $middlewares,
private Closure $fallbackHandler,
private Container $container
) {
$this->invoker = new MiddlewareInvoker($this->container);
$this->logger = $this->container->get(Logger::class);
}
public function handle(Request $request): Response
{
// Kontext mit dem Request erstellen
$context = new MiddlewareContext($request);
$this->stateManager = new MiddlewareStateManager()->forRequest($request);
// Middleware-Stack durchlaufen
$resultContext = $this->processMiddlewareStack($context, 0);
// Am Ende die Response aus dem Kontext zurückgeben
if ($resultContext->hasResponse()) {
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),
]);
// 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;
}
private function processMiddlewareStack(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);
// 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!");
}
return $resultContext;
};
// Middleware mit dem Invoker ausführen
$resultContext = $this->invoker->invoke($middleware, $context, $next, $this->stateManager);
// 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;
}
/**
* Ermittelt einen lesbaren Namen für die Middleware
*/
private function getMiddlewareName(mixed $middleware): string
{
if (is_string($middleware)) {
return $middleware;
}
if (is_object($middleware)) {
$className = get_class($middleware);
return substr($className, strrpos($className, '\\') + 1);
}
return gettype($middleware);
}
/**
* Debug-Logging mit Context-Informationen
*/
private function logDebug(string $message, MiddlewareContext $context, int $index): void
{
$responseStatus = $context->hasResponse() ?
"Response✅ (Status: " . ($context->response->statusCode ?? '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,
]);
*/
}
/**
* Info-Logging für wichtige Events
*/
private function logInfo(string $message, array $context = []): void
{
$this->logger->info('Middleware Chain: {message}', array_merge([
'message' => $message,
'component' => 'MiddlewareChain'
], $context));
}
/**
* Error-Logging für Probleme
*/
private 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)
], $context));
}
}

View File

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

View File

@@ -4,15 +4,30 @@ declare(strict_types=1);
namespace App\Framework\Http;
final class HttpRequest implements Request
use App\Framework\Http\Cookies\Cookies;
final readonly class HttpRequest implements Request
{
#public array $parsedBody;
public function __construct(
public readonly HttpMethod $method = HttpMethod::GET,
readonly Headers $headers = new Headers(),
readonly string $body = '',
readonly string $path = '',
readonly array $files = [],
readonly Cookies $cookies = new Cookies()
public Method $method = Method::GET,
public Headers $headers = new Headers(),
public string $body = '',
public string $path = '',
public array $queryParams = [],
public UploadedFiles $files = new UploadedFiles([]),
public Cookies $cookies = new Cookies(),
public ServerEnvironment $server = new ServerEnvironment(),
public RequestId $id = new RequestId(),
public RequestBody $parsedBody = new RequestBody(Method::GET, new Headers(), '', [])
) {
#$this->parsedBody = new RequestBodyParser()->parse($this);
}
// Hilfsmethode zum Abrufen von Query-Parametern
public function getQuery(string $key, mixed $default = null): mixed
{
return $this->queryParams[$key] ?? $default;
}
}

View File

@@ -10,11 +10,5 @@ final readonly class HttpResponse implements Response
public Status $status = Status::OK,
public Headers $headers = new Headers(),
public string $body = ''
/*public private(set) string $body = '' {
get => $this->body;
set => $value;
}*/
) {
}
) {}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Framework\Http;
/**
* Value Object für IP-Adressen
*/
final readonly class IpAddress
{
public function __construct(
public string $value
) {
if (!filter_var($value, FILTER_VALIDATE_IP)) {
throw new \InvalidArgumentException("Invalid IP address: {$value}");
}
}
public function isPrivate(): bool
{
return !filter_var($this->value, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE);
}
public function __toString(): string
{
return $this->value;
}
}

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Framework\Http;
enum HttpMethod: string
enum Method: string
{
case GET = 'GET';
case POST = 'POST';
@@ -15,5 +15,4 @@ enum HttpMethod: string
case OPTIONS = 'OPTIONS';
case TRACE = 'TRACE';
case CONNECT = 'CONNECT';
}

View File

@@ -0,0 +1,159 @@
<?php
namespace App\Framework\Http\Middleware;
use App\Framework\Core\PathProvider;
use App\Framework\Http\Headers;
use App\Framework\Http\HttpResponse;
use App\Framework\Http\Middleware;
use App\Framework\Http\Request;
use App\Framework\Http\Status;
class ServeStaticFilesMiddleware implements Middleware
{
private array $allowedExtensions = [
'jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'svg',
'css', 'js', 'woff', 'woff2', 'ttf', 'eot',
'pdf', 'ico', 'xml', 'json'
];
private array $mimeTypes = [
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'webp' => 'image/webp',
'avif' => 'image/avif',
'svg' => 'image/svg+xml',
'css' => 'text/css',
'js' => 'application/javascript',
'woff' => 'font/woff',
'woff2' => 'font/woff2',
'ttf' => 'font/ttf',
'eot' => 'application/vnd.ms-fontobject',
'pdf' => 'application/pdf',
'ico' => 'image/x-icon',
'xml' => 'application/xml',
'json' => 'application/json'
];
public function __construct(
private PathProvider $pathProvider,
private string $mediaPrefix = '/media'
) {}
public function process(Request $request, callable $next): HttpResponse
{
$path = $request->getPath();
// 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);
}
return $next($request);
}
private function serveStaticFile(string $filePath): HttpResponse
{
// Pfad bereinigen (verhindert Directory Traversal Angriffe)
$filePath = $this->sanitizePath($filePath);
// Dateiendung prüfen
$extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
if (!in_array($extension, $this->allowedExtensions)) {
return $this->notFound();
}
// Vollständigen Dateipfad konstruieren
$fullPath = $this->pathProvider->resolvePath('storage' . $filePath);
// Prüfen ob Datei existiert
if (!file_exists($fullPath) || !is_file($fullPath)) {
return $this->notFound();
}
// MIME-Typ ermitteln
$mimeType = $this->mimeTypes[$extension] ?? 'application/octet-stream';
// Datei auslesen
$content = file_get_contents($fullPath);
// Cache-Control Header basierend auf Dateityp setzen
$cacheControl = $this->getCacheControlHeader($extension);
// ETag für Caching generieren
$etag = '"' . md5_file($fullPath) . '"';
// Last-Modified Header
$lastModified = gmdate('D, d M Y H:i:s', filemtime($fullPath)) . ' GMT';
// Headers zusammenstellen
$headers = new Headers([
'Content-Type' => $mimeType,
'Content-Length' => filesize($fullPath),
'Cache-Control' => $cacheControl,
'ETag' => $etag,
'Last-Modified' => $lastModified
]);
return new HttpResponse(
Status::OK,
$headers,
$content
);
}
private function sanitizePath(string $path): string
{
// Doppelte Slashes entfernen
$path = preg_replace('#/+#', '/', $path);
// Führende/nachfolgende Slashes entfernen
$path = trim($path, '/');
// Sicherstellen, dass kein '..' enthalten ist (verhindert Directory Traversal)
$parts = [];
foreach (explode('/', $path) as $part) {
if ($part === '.') {
continue;
}
if ($part === '..') {
array_pop($parts);
} else {
$parts[] = $part;
}
}
return '/' . implode('/', $parts);
}
private function getCacheControlHeader(string $extension): string
{
// Bilder und Fonts können länger gecacht werden
$imageFontExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'svg', 'woff', 'woff2', 'ttf', 'eot'];
if (in_array($extension, $imageFontExtensions)) {
return 'public, max-age=31536000'; // 1 Jahr
}
// CSS und JS
if (in_array($extension, ['css', 'js'])) {
return 'public, max-age=604800'; // 1 Woche
}
// Sonstige Dateien
return 'public, max-age=86400'; // 1 Tag
}
private function notFound(): HttpResponse
{
return new HttpResponse(
Status::NOT_FOUND,
new Headers(['Content-Type' => 'text/plain']),
'Datei nicht gefunden'
);
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
/**
* Kontextobjekt, das durch die Middleware-Kette weitergegeben wird.
* Es enthält sowohl den ursprünglichen Request als auch eine optionale Response.
*/
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
*/
public function withResponse(Response $response): self
{
return new self($this->request, $response);
}
/**
* Prüft, ob bereits eine Response im Kontext vorhanden ist
*/
public function hasResponse(): bool
{
return $this->response !== null;
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
use App\Framework\DI\Container;
use App\Framework\Logging\DefaultLogger;
final readonly class MiddlewareInvoker
{
private DefaultLogger $logger;
public function __construct(
private Container $container
) {
$this->logger = $this->container->get(DefaultLogger::class);
}
/**
* Führt eine einzelne Middleware aus und gibt den resultierenden Kontext zurück
*/
public function invoke(
HttpMiddleware|string $middleware,
MiddlewareContext $context,
callable $next,
RequestStateManager $stateManager
): MiddlewareContext {
// Middleware-Instanz holen, falls ein Klassenname übergeben wurde
if (is_string($middleware)) {
$middleware = $this->container->get($middleware);
}
// Debug-Ausgabe vor der Ausführung
$this->logger->debug('VOR: Middleware ' . get_class($middleware) .
' - hasResponse: ' . ($context->hasResponse() ? 'ja' : 'nein'));
try {
// Middleware ausführen
/* @var $middleware HttpMiddleware */
$resultContext = $middleware($context, $next, $stateManager);
// Typüberprüfung
if (!$resultContext instanceof MiddlewareContext) {
throw new \RuntimeException(sprintf(
'Middleware %s hat keinen MiddlewareContext zurückgegeben, sondern %s',
get_class($middleware),
is_object($resultContext) ? get_class($resultContext) : gettype($resultContext)
));
}
// Debug-Ausgabe nach der Ausführung
$this->logger->debug('NACH: Middleware ' . get_class($middleware) .
' - hasResponse: ' . ($resultContext->hasResponse() ? 'ja' : 'nein'));
return $resultContext;
} catch (\Throwable $e) {
$this->logger->error('Fehler in Middleware ' . get_class($middleware) . ': ' . $e->getMessage());
throw $e;
// Bei Fehler den ursprünglichen Kontext zurückgeben
return $context;
}
}
}

View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
use App\Framework\Core\InterfaceImplementationLocator;
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;
/**
* Verwaltet die HTTP-Middleware-Pipeline
*/
final readonly class MiddlewareManager
{
/**
* @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
];*/
$middlewares = $this->buildMiddlewareStack();
foreach($middlewares as $middleware) {
#echo $middleware . " " . $this->getMiddlewarePriority($middleware) . "<br>". PHP_EOL;
}
$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);
}
/**
* Erstellt eine Middleware-Chain mit den registrierten Middlewares
*/
/*public function createMiddlewareChain(callable $fallbackHandler): HttpMiddlewareChain
{
return new HttpMiddlewareChain(
$this->middlewares,
$fallbackHandler,
$this->container
);
}*/
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;
}
return $attr->priority->value;
}
// Standard-Priorität (oder Fehlermeldung)
return MiddlewarePriority::BUSINESS_LOGIC->value;
}
// Methode zum Sortieren der Middleware nach Priorität
private function sortMiddlewaresByPriority($middlewares): array
{
usort($middlewares, function (object|string $a, object|string $b) {
$priorityA = $this->getMiddlewarePriority($a);
$priorityB = $this->getMiddlewarePriority($b);
return $priorityB <=> $priorityA; // Höhere Priorität zuerst
});
return $middlewares;
}
// Hilfsmethode zum Abrufen der Middleware-Priorität
private function getMiddlewarePriority(object|string $middlewareClass): int
{
$priority = $this->discoveryResults->get(MiddlewarePriorityAttribute::class);
$middlewareClass = ltrim($middlewareClass, '\\');
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;
}
return $priority + ($item['attribute_data']['offset'] ?? 0);
}
}
// Middleware has no Priority: Set to default
return MiddlewarePriority::BUSINESS_LOGIC->value;
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
use Attribute;
#[\Attribute(Attribute::TARGET_CLASS)]
enum MiddlewarePriority: int
{
// 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 CORS = 700;
// Anwendungsebene
case ROUTING = 500;
case CONTENT_NEGOTIATION = 400;
case AUTHENTICATION = 300;
case AUTHORIZATION = 200;
// Geschäftslogik
case BUSINESS_LOGIC = 100;
// Logging und Monitoring - wird nach allem anderen ausgeführt
case LOGGING = 0;
/**
* Hilfsmethode zum Erzeugen einer benutzerdefinierten Priorität
* innerhalb eines bestimmten Bereichs
*/
/*public static function custom(self $baseLevel, int $offset = 0): int
{
return $baseLevel->value + $offset;
}*/
/**
* Gibt eine lesbare Beschreibung der Priorität zurück
*/
public function getDescription(): string
{
return match($this) {
self::VERY_EARLY => 'Sehr frühe Ausführung (Request-ID)',
self::ERROR_HANDLING => 'Fehlerbehandlung',
self::SECURITY => 'Sicherheit (Firewall, CSRF)',
self::SESSION => 'Session-Verwaltung',
self::CORS => 'CORS-Header',
self::ROUTING => 'Routing und URL-Rewriting',
self::CONTENT_NEGOTIATION => 'Content-Negotiation',
self::AUTHENTICATION => 'Authentifizierung',
self::AUTHORIZATION => 'Autorisierung',
self::BUSINESS_LOGIC => 'Geschäftslogik',
self::LOGGING => 'Logging (späteste Ausführung)',
};
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
use Attribute;
#[Attribute(Attribute::TARGET_CLASS)]
class MiddlewarePriorityAttribute
{
public function __construct(
public MiddlewarePriority $priority,
public int $offset = 0
) {}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Framework\Http;
use WeakMap;
class MiddlewareStateManager
{
private Weakmap $requestStates;
public function __construct()
{
$this->requestStates = new WeakMap();
}
public function forRequest(Request $request): RequestStateManager
{
return new RequestStateManager($this->requestStates, $request);
}
}

View File

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

View File

@@ -0,0 +1,53 @@
<?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\RequestStateManager;
use App\Framework\Http\Request;
use App\Framework\Http\Response;
use App\Framework\Http\ResponseManipulator;
#[MiddlewarePriorityAttribute(MiddlewarePriority::CORS)]
final readonly class CORSMiddleware implements HttpMiddleware
{
public function __construct(
private ResponseManipulator $manipulator
) {}
public function __invoke(MiddlewareContext $context, callable $next, RequestStateManager $stateManager): MiddlewareContext
{
// Debug-Ausgabe vor der Verarbeitung
#var_dump('CORS Middleware Start - Kontext hat Response: ' . ($context->hasResponse() ? 'ja' : 'nein'));
// Nächste Middleware aufrufen UND den Kontext mit der Response behalten
$resultContext = $next($context);
// Debug-Ausgabe nach der Verarbeitung
#var_dump('CORS Middleware End - Kontext hat Response: ' . ($resultContext->hasResponse() ? 'ja' : 'nein'));
// Wenn eine Response vorhanden ist, CORS-Header hinzufügen
if ($resultContext->hasResponse()) {
$response = $resultContext->response;
// Aktualisierte Headers erstellen
$updatedHeaders = $response->headers->with('Access-Control-Allow-Origin', '*');
$updatedHeaders = $updatedHeaders->with('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
$updatedHeaders = $updatedHeaders->with('Access-Control-Allow-Headers', 'Content-Type, Authorization');
// Neue Response mit aktualisierten Headers erstellen
$updatedResponse = $this->manipulator->withHeaders($response, $updatedHeaders);
// Kontext mit der aktualisierten Response zurückgeben
return $resultContext->withResponse($updatedResponse);
}
// Wenn keine Response vorhanden ist, Kontext unverändert zurückgeben
return $resultContext;
}
}

View File

@@ -0,0 +1,29 @@
<?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\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
{
return $next($context);
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Middlewares;
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\RequestStateManager;
use App\Framework\Http\Session\Session;
#[MiddlewarePriorityAttribute(MiddlewarePriority::SECURITY, -150)] // Push after Session Creation
final readonly class CsrfMiddleware implements HttpMiddleware
{
public function __construct(
private Session $session,
){}
public function __invoke(MiddlewareContext $context, callable $next, RequestStateManager $stateManager): MiddlewareContext
{
$request = $context->request;
if (!$this->session->isStarted()) {
throw new \RuntimeException('Session must be started before CSRF validation');
}
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');
}
}
return $next($context);
}
}

View File

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

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Middlewares;
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\RequestStateManager;
use Psr\Log\LoggerInterface;
#[MiddlewarePriorityAttribute(MiddlewarePriority::SECURITY, -140)] // Nach CSRF, vor anderen Validierungen
final readonly class HoneypotMiddleware implements HttpMiddleware
{
public function __construct(
private ?LoggerInterface $logger = null
) {}
public function __invoke(MiddlewareContext $context, callable $next, RequestStateManager $stateManager): MiddlewareContext
{
$request = $context->request;
if ($request->method === Method::POST) {
$this->validateHoneypot($request);
}
return $next($context);
}
private function validateHoneypot($request): void
{
$honeypotName = $request->parsedBody->get('_honeypot_name');
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)) {
$this->logSuspiciousActivity("Honeypot filled: {$honeypotName} = {$honeypotValue}", $request);
throw new \Exception('Spam-Schutz ausgelöst');
}
// Zusätzliche Zeit-basierte Validierung (optional)
$this->validateSubmissionTime($request);
}
private function validateSubmissionTime($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
{
if ($this->logger) {
$this->logger->warning('Honeypot triggered', [
'reason' => $reason,
'ip' => $request->getClientIp(),
'user_agent' => $request->headers->get('User-Agent') ?? 'unknown',
'url' => $request->uri
]);
}
}
}

View File

@@ -0,0 +1,56 @@
<?php
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\RequestStateManager;
use App\Framework\Logging\DefaultLogger;
#[MiddlewarePriorityAttribute(MiddlewarePriority::LOGGING)]
final readonly class LoggingMiddleware implements HttpMiddleware
{
public function __construct(
private DefaultLogger $logger
) {}
public function __invoke(MiddlewareContext $context, callable $next, RequestStateManager $stateManager): MiddlewareContext
{
$start = microtime(true);
// Request-Informationen loggen
$this->logger->info("HTTP Request", [
'path' => $context->request->path,
'method' => $context->request->method->value,
'query' => $context->request->queryParams,
]);
// Nachfolgende Middlewares aufrufen
$resultContext = $next($context);
// Dauer berechnen
$duration = number_format((microtime(true) - $start) * 1000, 2);
// Status bestimmen
$status = $resultContext->hasResponse()
? $resultContext->response->status->value
: 'keine Response';
// Response-Informationen loggen - Warnung bei Fehlercodes
if ($resultContext->hasResponse() && $resultContext->response->status->value >= 400) {
$this->logger->warning("HTTP Response", [
'status' => $status,
'duration_ms' => $duration,
]);
} else {
$this->logger->info("HTTP Response", [
'status' => $status,
'duration_ms' => $duration,
]);
}
return $resultContext;
}
}

View File

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

View File

@@ -0,0 +1,46 @@
<?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\RequestStateManager;
use App\Framework\Http\ResponseManipulator;
#[MiddlewarePriorityAttribute(MiddlewarePriority::SECURITY)]
final readonly class RemovePoweredByMiddleware implements HttpMiddleware
{
public function __construct(
private ResponseManipulator $manipulator
) {}
public function __invoke(MiddlewareContext $context, callable $next, RequestStateManager $stateManager): MiddlewareContext
{
// Nächste Middleware aufrufen
$resultContext = $next($context);
// Wenn eine Response vorhanden ist, X-Powered-By Header entfernen
if ($resultContext->hasResponse()) {
$response = $resultContext->response;
$updatedHeaders = $response->headers;
// X-Powered-By Header entfernen, falls vorhanden
if ($updatedHeaders->has('X-Powered-By')) {
$updatedHeaders = $updatedHeaders->without('X-Powered-By');
// Neue Response mit aktualisierten Headers erstellen
$updatedResponse = $this->manipulator->withHeaders($response, $updatedHeaders);
return $resultContext->withResponse($updatedResponse);
}
}
return $resultContext;
}
}

View File

@@ -0,0 +1,48 @@
<?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\RequestStateManager;
use App\Framework\Http\RequestIdGenerator;
use App\Framework\Http\ResponseManipulator;
/**
* Middleware zur Verarbeitung von Request-IDs.
* Generiert eine HMAC-signierte Request-ID und fügt sie als Header hinzu.
*/
#[MiddlewarePriorityAttribute(MiddlewarePriority::VERY_EARLY)]
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
{
// Request-ID generieren/validieren
$requestId = $this->requestIdGenerator->generate();
// Request-ID an den weiteren Verarbeitungskontext weitergeben
$resultContext = $next($context);
// Request-ID in die Response-Header setzen, falls eine Response vorhanden ist
if ($resultContext->hasResponse()) {
$response = $resultContext->response;
$headers = $response->headers->with(
$this->requestIdGenerator::getHeaderName(),
$requestId->toString()
);
$updatedResponse = $this->manipulator->withHeaders($response, $headers);
return $resultContext->withResponse($updatedResponse);
}
return $resultContext;
}
}

View File

@@ -0,0 +1,59 @@
<?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\RequestStateManager;
use App\Framework\Http\Request;
use App\Framework\Http\Response;
use App\Framework\Logging\Logger;
#[MiddlewarePriorityAttribute(MiddlewarePriority::LOGGING)]
final readonly class RequestLoggingMiddleware implements HttpMiddleware
{
public function __construct(
private Logger $logger
){}
public function __invoke(MiddlewareContext $context, callable $next, RequestStateManager $stateManager): MiddlewareContext
{
$startTime = microtime(true);
$request = $context->request;
// Anfrage loggen
$path = $request->path;
$method = $request->method;
// Nächste Middleware aufrufen
$resultContext = $next($context);
// Antwort loggen
$endTime = microtime(true);
$duration = round(($endTime - $startTime) * 1000, 2);
// Statuscode nur abrufen, wenn eine Response vorhanden ist
$status = $resultContext->hasResponse()
? $resultContext->response->status->value
: 'keine Response';
// Log-Eintrag erstellen
$logEntry = sprintf(
'[%s] %s %s - %d (%s ms)',
date('Y-m-d H:i:s'),
$method->value,
$path,
$status,
$duration
);
$this->logger->info($logEntry);
// In Log-Datei schreiben...
return $resultContext;
}
}

View File

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

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Middlewares;
use App\Framework\Auth\Auth;
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\Meta\MetaData;
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;
#[MiddlewarePriorityAttribute(MiddlewarePriority::ROUTING)]
final readonly class RoutingMiddleware implements HttpMiddleware
{
public function __construct(
private HttpRouter $router,
private RouteDispatcher $dispatcher,
) {}
/**
* Invokes the middleware logic to process a request and response through the provided context.
* It enriches the request based on the matched route and dispatches the corresponding controller logic.
* 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 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
{
$request = $context->request;
$routeContext = $this->router->match($request);
if (!$routeContext->isSuccess()) {
throw new RouteNotFound($routeContext->path);
}
if (in_array(Auth::class, $routeContext->match->route->attributes)) {
#debug($request->server->getClientIp());
$wireguardIp = '172.20.0.1';
$ip = $request->server->getClientIp();
if ($ip->value !== $wireguardIp) {
throw new RouteNotFound($routeContext->path);
}
}
// Controller-Logik ausführen
$result = $this->dispatcher->dispatch($routeContext);
if ($result instanceof ContentNegotiationResult) {
$result = $this->contentNegotiation($result);
}
$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;
// Nächste Middleware aufrufen
return $next($updatedContext);
}
private function contentNegotiation(ContentNegotiationResult $response): ActionResult
{
$isJson = true;
if ($isJson) {
return new JsonResult($response->jsonPayload);
}
if ($response->redirectTo !== null) {
return new Redirect($response->redirectTo);
}
if ($response->viewTemplate !== null) {
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']);
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Middlewares;
final readonly class SecurityHeaderConfig
{
public function __construct(
public string $hstsHeader = 'max-age=63072000; includeSubDomains; preload',
public string $frameOptions = 'DENY',
public string $referrerPolicy = 'strict-origin-when-cross-origin',
public string $contentSecurityPolicy = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; media-src 'self'; object-src 'none'; child-src 'self'; frame-ancestors 'none'; form-action 'self'; base-uri 'self'",
public string $permissionsPolicy = 'geolocation=(), microphone=(), camera=()',
public string $crossOriginEmbedderPolicy = 'require-corp',
public string $crossOriginOpenerPolicy = 'same-origin',
public string $crossOriginResourcePolicy = 'same-origin',
public bool $enableInDevelopment = false
) {}
/**
* Erstellt eine Konfiguration für Entwicklungsumgebung mit weniger restriktiven Einstellungen
*/
public static function forDevelopment(): self
{
return new self(
hstsHeader: 'max-age=3600', // Kürzere HSTS-Zeit für Development
frameOptions: 'SAMEORIGIN', // Weniger restriktiv für Development-Tools
contentSecurityPolicy: "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' https:; connect-src 'self' ws: wss:; media-src 'self'; object-src 'none'; child-src 'self'; frame-ancestors 'self'; form-action 'self'; base-uri 'self'",
crossOriginEmbedderPolicy: 'unsafe-none',
crossOriginOpenerPolicy: 'unsafe-none',
crossOriginResourcePolicy: 'cross-origin',
enableInDevelopment: true
);
}
/**
* Erstellt eine Konfiguration für Produktionsumgebung mit maximaler Sicherheit
*/
public static function forProduction(): self
{
return new self(
hstsHeader: 'max-age=63072000; includeSubDomains; preload',
frameOptions: 'DENY',
contentSecurityPolicy: "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; media-src 'self'; object-src 'none'; child-src 'none'; frame-ancestors 'none'; form-action 'self'; base-uri 'self'; upgrade-insecure-requests",
permissionsPolicy: 'geolocation=(), microphone=(), camera=(), payment=(), usb=(), magnetometer=(), gyroscope=(), speaker=()',
);
}
}

View File

@@ -0,0 +1,78 @@
<?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\RequestStateManager;
use App\Framework\Http\ResponseManipulator;
#[MiddlewarePriorityAttribute(MiddlewarePriority::SECURITY)]
final readonly class SecurityHeaderMiddleware implements HttpMiddleware
{
public function __construct(
private ResponseManipulator $manipulator,
private SecurityHeaderConfig $config = new SecurityHeaderConfig()
) {}
public function __invoke(MiddlewareContext $context, callable $next, RequestStateManager $stateManager): MiddlewareContext
{
$this->removePoweredByHeader();
// Nächste Middleware aufrufen
$resultContext = $next($context);
// Wenn eine Response vorhanden ist, Security-Header hinzufügen
if ($resultContext->hasResponse()) {
$response = $resultContext->response;
$updatedHeaders = $response->headers;
// Alle Security-Header hinzufügen
foreach ($this->getSecurityHeaders() as $name => $value) {
if ($this->shouldAddHeader($name, $updatedHeaders)) {
$updatedHeaders = $updatedHeaders->with($name, $value);
}
}
// Neue Response mit aktualisierten Headers erstellen
$updatedResponse = $this->manipulator->withHeaders($response, $updatedHeaders);
return $resultContext->withResponse($updatedResponse);
}
return $resultContext;
}
private function getSecurityHeaders(): array
{
return [
'Strict-Transport-Security' => $this->config->hstsHeader,
'X-Frame-Options' => $this->config->frameOptions,
'X-Content-Type-Options' => 'nosniff',
'Referrer-Policy' => $this->config->referrerPolicy,
'Content-Security-Policy' => $this->config->contentSecurityPolicy,
'Permissions-Policy' => $this->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,
];
}
private function shouldAddHeader(string $headerName, $currentHeaders): bool
{
// Header nur hinzufügen, wenn er noch nicht gesetzt ist
return !$currentHeaders->has($headerName);
}
private function removePoweredByHeader():void
{
if (!headers_sent()) {
header_remove('X-Powered-By');
header_remove('Server');
}
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Framework\Http;
enum MimeType: string
{
// Text
case TEXT_PLAIN = 'text/plain';
case TEXT_HTML = 'text/html';
case TEXT_CSS = 'text/css';
case TEXT_CSV = 'text/csv';
case TEXT_JAVASCRIPT = 'text/javascript';
// Application
case APPLICATION_JSON = 'application/json';
case APPLICATION_XML = 'application/xml';
case APPLICATION_FORM = 'application/x-www-form-urlencoded';
case APPLICATION_OCTET_STREAM = 'application/octet-stream';
case APPLICATION_PDF = 'application/pdf';
case APPLICATION_ZIP = 'application/zip';
// Images
case IMAGE_JPEG = 'image/jpeg';
case IMAGE_PNG = 'image/png';
case IMAGE_GIF = 'image/gif';
case IMAGE_SVG = 'image/svg+xml';
case IMAGE_WEBP = 'image/webp';
// Audio
case AUDIO_MPEG = 'audio/mpeg';
case AUDIO_WAV = 'audio/wav';
case AUDIO_OGG = 'audio/ogg';
// Video
case VIDEO_MP4 = 'video/mp4';
case VIDEO_WEBM = 'video/webm';
// Multipart
case MULTIPART_FORM_DATA = 'multipart/form-data';
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Framework\Http;
/**
*
* class AuthMiddleware
* {
* public function __invoke($context, $next, $state)
* {
* $auth = $state->namespace('auth');
*
* $auth->set('user', $user); Intern: 'auth.user'
* $auth->set('permissions', $perms); Intern: 'auth.permissions'
*
* return $next($context);
* }
* }
*/
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
{
return $this->stateManager->get("{$this->namespace}.{$key}", $default);
}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
final readonly class Range
{
public int $length;
public function __construct(
public int $start,
public int $end,
public int $total
) {
$this->length = $end - $start + 1;
}
}

View File

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

View File

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

View File

@@ -4,19 +4,23 @@ declare(strict_types=1);
namespace App\Framework\Http;
use App\Framework\Http\Cookies\Cookies;
interface Request
{
public Headers $headers{
get;
}
public Headers $headers { get; }
public string $body{
get;
}
public RequestId $id {get; }
public HttpMethod $method{
get;
}
public string $body { get; }
public Method $method { get; }
public array $queryParams { get; }
public string $path { get; }
public Cookies $cookies { get; }
public ServerEnvironment $server {get;}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Framework\Http;
final readonly class RequestBody
{
public array $data;
public function __construct(Method $method, Headers $headers, string $body, array $post)
{
if ($method === Method::GET) {
$this->data = $_GET;
return;
}
$contentType = $headers->get(HeaderKey::CONTENT_TYPE);
// Prüfen, ob $contentType ein Array ist und konvertiere es ggf. in einen String
if (is_array($contentType)) {
$contentType = $contentType[0] ?? '';
} elseif ($contentType === null) {
$contentType = '';
}
if (str_contains($contentType, MimeType::APPLICATION_JSON->value)) {
$parsedBody = 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;
} else {
// Fallback
$parsedBody = [];
}
$this->data = $parsedBody;
}
public function get(string $key, mixed $default = null): mixed
{
return $this->data[$key] ?? $default;
}
}

View File

@@ -0,0 +1,102 @@
<?php
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;
/**
* Erstellt HTTP-Requests aus den globalen Variablen
*/
final readonly class RequestFactory
{
public function __construct(
private RequestIdGenerator $requestIdGenerator
) {}
/**
* Erstellt einen HTTP-Request aus den globalen Variablen
*/
#[Initializer]
public function createFromGlobals(): Request
{
$server = new ServerEnvironment($_SERVER);
$originalRequestUri = (string)$server->getRequestUri();
$rawPath = parse_url($originalRequestUri, PHP_URL_PATH);
if ($rawPath === false) {
throw new RouteNotFound($originalRequestUri);
}
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
);
}
/**
* Extrahiert HTTP-Header aus den Server-Variablen
*/
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;
}
private function getCookiesFromServer(): Cookies
{
$cookies = [];
foreach ($_COOKIE as $key => $value) {
$cookies[] = new Cookie($key, $value);
}
return new Cookies(...$cookies);
}
}

View File

@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
/**
* Repräsentiert eine kryptografisch sichere und signierte Request-ID.
* Enthält sowohl die ID selbst als auch deren HMAC-Signatur zur Validierung.
*/
final readonly class RequestId
{
private string $id;
private string $signature;
private string $combined;
/**
* Erstellt eine neue Request-ID oder parsed eine bestehende
*
* @param string|null $combined Wenn nicht null, wird diese ID validiert und verwendet
* @param string $secret Das Secret für die HMAC-Signatur
*/
public function __construct(?string $combined = null, string $secret = '')
{
// Secret über eine Umgebungsvariable beziehen, falls nicht angegeben
$secret = $secret ?: ($_ENV['APP_SECRET'] ?? 'default-secret-change-me');
if ($combined !== null && self::isValidFormat($combined)) {
// Bestehende ID parsen
[$rawId, $signature] = explode('.', $combined, 2);
// Signatur validieren
$expectedSignature = $this->generateSignature($rawId, $secret);
if (!hash_equals($expectedSignature, $signature)) {
// Bei ungültiger Signatur, neue ID erstellen
$this->generateNew($secret);
} else {
// Bei gültiger Signatur, diese ID verwenden
$this->id = $rawId;
$this->signature = $signature;
$this->combined = $combined;
}
} else {
// Neue ID erstellen
$this->generateNew($secret);
}
}
/**
* Generiert eine neue Request-ID mit Signatur
*/
private function generateNew(string $secret): void
{
// Zufällige ID generieren (16 Byte = 32 Hex-Zeichen)
$this->id = bin2hex(random_bytes(16));
// Signatur erstellen
$this->signature = $this->generateSignature($this->id, $secret);
// Kombinierte Darstellung erstellen
$this->combined = $this->id . '.' . $this->signature;
}
/**
* Generiert eine HMAC-Signatur für die ID
*/
private function generateSignature(string $id, string $secret): string
{
return hash_hmac('sha256', $id, $secret, false);
}
/**
* Prüft, ob eine kombinierte ID das korrekte Format hat
*/
private static function isValidFormat(string $combined): bool
{
// Format: 32 Hex-Zeichen + Punkt + 64 Hex-Zeichen (SHA-256 HMAC)
return preg_match('/^[a-f0-9]{32}\.[a-f0-9]{64}$/i', $combined) === 1;
}
/**
* Gibt die kombinierte ID (ID + Signatur) zurück
*/
public function toString(): string
{
return $this->combined;
}
/**
* Gibt nur die eigentliche ID ohne Signatur zurück
*/
public function getId(): string
{
return $this->id;
}
/**
* Gibt die Signatur zurück
*/
public function getSignature(): string
{
return $this->signature;
}
/**
* String-Repräsentation für Debugging
*/
public function __toString(): string
{
return $this->toString();
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
use App\Framework\Attributes\Singleton;
/**
* Service zur Verwaltung der Request-ID für den aktuellen Request.
* Nutzt HMAC-Signierung, um die Integrität der IDs zu gewährleisten.
*/
#[Singleton]
final class RequestIdGenerator
{
private const string REQUEST_ID_HEADER = 'X-Request-ID';
private ?RequestId $requestId = null;
private string $secret;
/**
* Initialisiert den RequestIdGenerator mit einem optionalen Secret
*/
public function __construct(string $secret = '')
{
// Secret über eine Umgebungsvariable beziehen, falls nicht angegeben
$this->secret = $secret ?: ($_ENV['APP_SECRET'] ?? 'default-secret-change-me');
}
/**
* Generiert eine neue Request-ID oder validiert und verwendet eine bestehende
*/
public function generate(): RequestId
{
if ($this->requestId === null) {
// Prüfen, ob eine Request-ID im Header vorhanden ist
$headerRequestId = $_SERVER[str_replace('-', '_', 'HTTP_' . self::REQUEST_ID_HEADER)] ?? null;
// Neue RequestId erstellen (validiert automatisch die Header-ID, falls vorhanden)
$this->requestId = new RequestId($headerRequestId, $this->secret);
}
return $this->requestId;
}
/**
* Gibt die aktuelle Request-ID zurück oder null, wenn noch keine generiert wurde
*/
public function getCurrentId(): ?RequestId
{
return $this->requestId;
}
/**
* Gibt den Header-Namen für die Request-ID zurück
*/
public static function getHeaderName(): string
{
return self::REQUEST_ID_HEADER;
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Framework\Http;
use WeakMap;
final class RequestStateManager
{
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
{
if(!isset($this->requestStates[$this->request])) {
$this->requestStates[$this->request] = [];
}
$this->requestStates[$this->request][$key] = $value;
}
public function get(string $key, mixed $default = null): mixed
{
return $this->requestStates[$this->request][$key] ?? $default;
}
public function has(string $key): bool
{
return isset($this->requestStates[$this->request][$key]);
}
public function remove(string $key): void
{
unset($this->requestStates[$this->request][$key]);
}
}

View File

@@ -6,15 +6,9 @@ namespace App\Framework\Http;
interface Response
{
public string $body{
get;
}
public string $body{ get; }
public Status $status{
get;
}
public Status $status{ get; }
public Headers $headers{
get;
}
public Headers $headers{ get; }
}

View File

@@ -4,38 +4,76 @@ declare(strict_types=1);
namespace App\Framework\Http;
final class ResponseEmitter
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 emit(Response $response): void
public function __construct() {}
public function emit(Response $response, int $chunkSize = 8192): void
{
// Status-Code senden
http_response_code($response->status->value);
$responseType = new ResponseTypeDetector()->detect($response);
// Header senden
/*foreach ($response->headers->all() as $name => $values) {
#foreach ((array)$values as $value) {
header("$name: $values", false);
#}
}*/
#dd($responseType);
// Header senden
foreach ($response->headers->all() as $name => $value) {
// Sicherheitsprüfung
if (!preg_match('/^[A-Za-z0-9\-]+$/', $name)) {
throw new \InvalidArgumentException("Invalid header name: '$name'");
}
match ($responseType) {
ResponseType::SSE => $this->emitSseResponse($response),
ResponseType::WEBSOCKET => $this->emitWebSocketResponse($response),
ResponseType::MEDIA_STREAM => $this->emitStreamResponse($response),
ResponseType::HLS, ResponseType::DASH => $this->emitAdaptiveStreamResponse($response),
default => $this->emitHttpResponse($response),
};
// Bei Mehrfach-Headern: ggf. als Array zulassen
if (is_array($value)) {
foreach ($value as $single) {
header("$name: $single", false); // false = nicht ersetzen
/*if (is_string($body)) {
// Wenn der Body ein String ist, direkt ausgeben.
echo $body;
} elseif (is_resource($body) && get_resource_type($body) === 'stream') {
// Wenn der Body eine Ressource (Stream) ist, in Chunks lesen und senden.
// Dies ist speichereffizient für grosse Dateien.
while (!feof($body)) {
echo fread($body, $chunkSize);
// Ggf. Output-Buffer leeren, um den Chunk sofort an den Client zu senden.
if (ob_get_level() > 0) {
ob_flush();
}
} else {
header("$name: $value", true);
flush();
}
}
fclose($body);
}*/
}
// Body ausgeben
echo $response->body;
/**
* Sendet eine SSE-Response mit Streaming-Unterstützung
*/
private function emitSseResponse(Response $response): void
{
new SseEmitter()->emit($response);
}
/**
* Sendet eine WebSocket-Response und startet die WebSocket-Verbindung
*/
private function emitWebSocketResponse(Response $response): void
{
new WebsocketEmitter()->emit($response);
}
private function emitHttpResponse(Response $response): void
{
new HttpEmitter()->emit($response);
}
private function emitStreamResponse(Response $response): void
{
new StreamEmitter()->emit($response);
}
private function emitAdaptiveStreamResponse(Response $response): void
{
new AdaptiveStreamEmitter()->emit($response);
}
}

View File

@@ -2,10 +2,18 @@
namespace App\Framework\Http;
use App\Framework\Http\Responses\SseResponse;
use App\Framework\Http\Responses\Streamable;
final readonly class ResponseManipulator
{
public function withBody(Response $response, string $body): Response
{
// Spezielle Response-Typen erhalten
if ($response instanceof Streamable) {
return $response; // SSE-Body wird gestreamt, nicht statisch gesetzt
}
return new HttpResponse(
status: $response->status,
headers: $response->headers,
@@ -15,8 +23,27 @@ final readonly class ResponseManipulator
public function withHeader(Response $response, string $name, string $value): Response
{
// Spezielle Response-Typen erhalten
if ($response instanceof Streamable) {
return $response; // SSE-Header sind spezifisch und sollten nicht geändert werden
}
$headers = clone $response->headers;
$headers->with($name, $value);
$headers = $headers->with($name, $value);
return new HttpResponse(
status: $response->status,
headers: $headers,
body: $response->body
);
}
public function withHeaders(Response $response, Headers $headers): Response
{
// Spezielle Response-Typen erhalten
if ($response instanceof Streamable) {
return $response;
}
return new HttpResponse(
status: $response->status,
headers: $headers,
@@ -26,6 +53,11 @@ final readonly class ResponseManipulator
public function withStatus(Response $response, Status $status): Response
{
// Spezielle Response-Typen erhalten
if ($response instanceof Streamable) {
return $response;
}
return new HttpResponse(
status: $status,
headers: $response->headers,

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
enum ResponseType: string
{
case HTTP = 'http';
case SSE = 'sse';
case WEBSOCKET = 'websocket';
case MEDIA_STREAM = 'media-stream';
case HLS = 'hls';
case DASH = 'dash';
}

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
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;
final readonly class ResponseTypeDetector
{
public function detect(Response $response): ResponseType
{
if($response instanceof SseResponse) {
return ResponseType::SSE;
}
if($response instanceof WebsocketResponse) {
return ResponseType::WEBSOCKET;
}
if($response instanceof AdaptiveStreamResponse) {
return match ($response->format) {
StreamingFormat::HLS => ResponseType::HLS,
StreamingFormat::DASH => ResponseType::DASH
};
}
if($response instanceof StreamResponse) {
return ResponseType::MEDIA_STREAM;
}
return $this->detectFromHeaders($response->headers);
}
private function detectFromHeaders(Headers $headers): ResponseType
{
if($this->isSseResponse($headers)) {
return ResponseType::SSE;
}
if($this->isWebSocketResponse($headers)) {
return ResponseType::WEBSOCKET;
}
$contentType = $headers->getFirst('Content-Type');
// HLS-Erkennung
if ($contentType === 'application/vnd.apple.mpegurl' ||
str_contains($contentType ?? '', 'mpegurl')) {
return ResponseType::HLS;
}
// DASH-Erkennung
if ($contentType === 'application/dash+xml' ||
str_contains($contentType ?? '', 'dash+xml')) {
return ResponseType::DASH;
}
if(MimeTypeDetector::isStreamable($contentType ?? '')) {
return ResponseType::MEDIA_STREAM;
}
return ResponseType::HTTP;
}
private function isSseResponse(Headers $headers): bool
{
$contentType = $headers->getFirst('Content-Type');
return $contentType === 'text/event-stream' ||
str_starts_with($contentType ?? '', 'text/event-stream');
}
private function isWebSocketResponse(Headers $headers): bool
{
$connection = strtolower($headers->getFirst('Connection') ?? '');
$upgrade = strtolower($headers->getFirst('Upgrade') ?? '');
return $connection === 'upgrade' && $upgrade === 'websocket';
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Responses;
use App\Framework\Http\HeaderKey;
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;
final readonly class AdaptiveStreamResponse implements Response, Streamable
{
public string $body;
public Headers $headers;
public function __construct(
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_METHODS, 'GET, HEAD, OPTIONS')
->with(HeaderKey::ACCESS_CONTROL_ALLOW_HEADERS, 'RANGE' );
// Format-spezifische Header setzen
#$this->headers = $this->headers->with('Content-Type', $this->format->getContentType());
// Standard Streaming-Header
#$this->headers = $this->headers
# ->with('Cache-Control', 'max-age=60')
# ->with('Access-Control-Allow-Origin', '*')
# ->with('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS')
# ->with('Access-Control-Allow-Headers', 'Range');
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Responses;
use App\Framework\Http\Headers;
use App\Framework\Http\Response;
use App\Framework\Http\Status;
final readonly class JsonResponse implements Response
{
public Headers $headers;
public string $body;
public function __construct(
array $body = [],
public Status $status = Status::OK,
) {
$this->headers = new Headers()->with('Content-Type', 'application/json');
$this->body = json_encode($body);
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Responses;
enum MediaType: string
{
case VIDEO = 'video';
case AUDIO = 'audio';
case UNKNOWN = 'unknown';
public static function fromMimeType(string $mimeType): self
{
return match (true) {
str_starts_with($mimeType, 'video/') => self::VIDEO,
str_starts_with($mimeType, 'audio/') => self::AUDIO,
default => self::UNKNOWN
};
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Framework\Http\Responses;
use App\Framework\Http\Headers;
use App\Framework\Http\Response;
use App\Framework\Http\Status;
final readonly class NotFound implements Response
{
public Status $status;
public Headers $headers;
public function __construct(
public string $body = 'Not Found',
) {
$this->status = Status::NOT_FOUND;
$this->headers = new Headers();
}
}

View File

@@ -1,18 +0,0 @@
<?php
namespace App\Framework\Http\Responses;
use App\Framework\Http\Headers;
use App\Framework\Http\Response;
use App\Framework\Http\Status;
class Redirect implements Response
{
public private(set) string $body = '';
public private(set) Status $status = Status::FOUND;
public \App\Framework\Http\Headers $headers {
get {
return new Headers()->with('Location', $this->body);
}
}
}

View File

@@ -0,0 +1,24 @@
<?php
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\Uri;
final readonly class RedirectResponse implements Response
{
public Headers $headers;
public string $body;
public Status $status;
public function __construct(
public Uri $location = new Uri('/'),
) {
$this->status = Status::FOUND;
$this->body = '';
$this->headers = new Headers()->with(HeaderKey::LOCATION, (string)$this->location);
}
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Responses;
use App\Framework\Http\Headers;
use App\Framework\Http\Response;
use App\Framework\Http\Status;
use App\Framework\Router\Result\SseEvent;
use App\Framework\Router\Result\SseResult;
use App\Framework\Router\Result\SseResultWithCallback;
use Closure;
/**
* Response-Klasse für Server-Sent Events
*/
final class SseResponse implements Response, Streamable
{
public readonly Status $status;
public readonly Headers $headers;
public readonly string $body;
public array $initialEvents = [];
/**
* @var int Maximale Streaming-Dauer in Sekunden (0 = unbegrenzt)
*/
public readonly int $maxDuration;
/**
* @var int Intervall für Heartbeats in Sekunden
*/
public readonly int $heartbeatInterval;
/**
* @param SseResult $sseResult Das SseResult, aus dem die Response erstellt wird
* @param Closure|null $streamCallback
*/
public function __construct(SseResult $sseResult, public readonly ?Closure $streamCallback = null)
{
$this->status = $sseResult->status;
$this->body = '';
// Standard SSE-Header
$headerData = [
'Content-Type' => 'text/event-stream',
'Cache-Control' => 'no-cache',
'Connection' => 'keep-alive',
'X-Accel-Buffering' => 'no', // Nginx buffering deaktivieren
];
// Benutzerdefinierte Header hinzufügen
foreach ($sseResult->headers as $name => $value) {
$headerData[$name] = $value;
}
$this->headers = new Headers($headerData);
// Initiale Events aus dem SseResult speichern
$this->initialEvents = $sseResult->getInitialEvents();
// Globales Retry-Interval setzen, falls definiert
if ($sseResult->retryInterval !== null) {
$this->initialEvents[] = new SseEvent('', null, null, $sseResult->retryInterval);
}
$this->maxDuration = $sseResult->maxDuration;
$this->heartbeatInterval = $sseResult->heartbeatInterval;;
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Responses;
use App\Framework\Http\Headers;
use App\Framework\Http\Range;
use App\Framework\Http\Response;
use App\Framework\Http\Status;
final readonly class StreamResponse implements Response, Streamable
{
public string $body;
public function __construct(
public Status $status = Status::OK,
public Headers $headers = new Headers(),
#public mixed $fileContent,
public string $filePath = '',
public int $fileSize = 0,
public string $mimeType = 'application/octet-stream',
public ?Range $range = null,
public MediaType $mediaType = MediaType::UNKNOWN,
) {
$this->body = '';
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Framework\Http\Responses;
interface Streamable
{
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Responses;
use App\Framework\Http\Headers;
use App\Framework\Http\Response;
use App\Framework\Http\Status;
use App\Framework\Router\Result\WebSocketResult;
/**
* Response-Klasse für WebSocket-Upgrade
*/
final readonly class WebSocketResponse implements Response, Streamable
{
public Status $status;
public Headers $headers;
public string $body;
public function __construct(
private WebSocketResult $webSocketResult,
private string $websocketKey
) {
$this->status = $webSocketResult->status;
// WebSocket-Upgrade-Header
$headerData = [
'Upgrade' => 'websocket',
'Connection' => 'Upgrade',
'Sec-WebSocket-Accept' => $this->generateAcceptKey($this->websocketKey),
];
// Subprotokoll hinzufügen, falls unterstützt
$subprotocols = $webSocketResult->getSubprotocols();
if (!empty($subprotocols)) {
$headerData['Sec-WebSocket-Protocol'] = implode(', ', $subprotocols);
}
// Benutzerdefinierte Header hinzufügen
foreach ($webSocketResult->headers as $name => $value) {
$headerData[$name] = $value;
}
$this->headers = new Headers($headerData);
}
/**
* Generiert den Accept-Key für WebSocket-Handshake
*/
private function generateAcceptKey(string $key): string
{
return base64_encode(hash('sha1', $key . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11', true));
}
public function getWebSocketResult(): WebSocketResult
{
return $this->webSocketResult;
}
}

View File

@@ -0,0 +1,184 @@
<?php
namespace App\Framework\Http;
/**
* Kapselt Server-Environment-Daten aus $_SERVER
*/
final readonly class ServerEnvironment
{
public function __construct(
private array $serverData = []
) {}
public static function fromGlobals(): self
{
return new self($_SERVER);
}
public function get(string|ServerKey $key, mixed $default = null): mixed
{
if($key instanceof ServerKey) {
$key = $key->value;
}
return $this->serverData[$key] ?? $default;
}
public function has(string|ServerKey $key): bool
{
!$key instanceof ServerKey ?: $key = $key->value;
return array_key_exists($key, $this->serverData);
}
// Häufig verwendete Server-Informationen als typisierte Methoden
public function getRemoteAddr(): IpAddress
{
$ip = $this->get(ServerKey::REMOTE_ADDR, '0.0.0.0');
return new IpAddress($ip);
}
public function getUserAgent(): string
{
return $this->get(ServerKey::HTTP_USER_AGENT, '');
}
public function getServerName(): string
{
return $this->get(ServerKey::SERVER_NAME, '');
}
public function getServerPort(): int
{
return (int) $this->get(ServerKey::SERVER_PORT, 80);
}
public function getRequestUri(): Uri
{
$uriString = $this->get(ServerKey::REQUEST_URI, '/');
return new Uri($uriString);
}
public function getScriptName(): string
{
return $this->get(ServerKey::SCRIPT_NAME, '');
}
public function getHttpHost(): string
{
return $this->get(ServerKey::HTTP_HOST, '');
}
public function isHttps(): bool
{
return $this->get(ServerKey::HTTPS) === 'on' ||
$this->get(ServerKey::SERVER_PORT) === '443' ||
$this->get(ServerKey::HTTP_X_FORWARDED_PROTO) === 'https';
}
public function getRequestMethod(): Method
{
$methodString = $this->get(ServerKey::REQUEST_METHOD, 'GET');
return Method::tryFrom($methodString) ?? Method::GET;
}
public function getQueryString(): string
{
return $this->get(ServerKey::QUERY_STRING, '');
}
public function getClientIp(): IpAddress
{
return new IpAddress($this->getClientIpString());
}
public function getProtocol(): ServerProtocol
{
$protocol = $this->get(ServerKey::SERVER_PROTOCOL);
return ServerProtocol::tryFrom($protocol) ?? ServerProtocol::HTTP_1_0;
}
private function getClientIpString(): string
{
// Priorisierte IP-Erkennung
$candidates = [
ServerKey::HTTP_X_REAL_IP,
ServerKey::HTTP_X_FORWARDED_FOR,
ServerKey::REMOTE_ADDR
];
foreach ($candidates as $key) {
$ip = $this->get($key);
if ($ip) {
// Bei X-Forwarded-For kann es mehrere IPs geben
if ($key === ServerKey::HTTP_X_FORWARDED_FOR) {
$ips = explode(',', $ip);
return trim($ips[0]);
}
return $ip;
}
}
return '0.0.0.0';
}
public function getReferer(): string
{
return $this->get(ServerKey::HTTP_REFERER, '');
}
public function getRefererUri(): Uri
{
$referer = $this->getReferer();
return new Uri($referer);
}
/**
* Prüft ob ein Referer gesetzt ist
*/
public function hasReferer(): bool
{
return !empty($this->get(ServerKey::HTTP_REFERER, null));
}
/**
* Prüft ob der Referer von der gleichen Domain stammt
*/
public function isRefererSameDomain(): bool
{
$referer = $this->getReferer();
if (empty($referer)) {
return false;
}
$refererHost = parse_url($referer, PHP_URL_HOST);
$currentHost = $this->getHttpHost();
return $refererHost === $currentHost;
}
/**
* Sichere Referer-URL für Redirects
*/
public function getSafeRefererUrl(string $fallback = '/'): string
{
$referer = $this->getReferer();
// Kein Referer gesetzt
if (empty($referer)) {
return $fallback;
}
// Nur interne Referer erlauben (CSRF-Schutz)
if (!$this->isRefererSameDomain()) {
return $fallback;
}
return $referer;
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Framework\Http;
/**
* Enum für $_SERVER-Array-Keys
* Bietet Typsicherheit und IDE-Unterstützung für Server-Variablen
*/
enum ServerKey: string
{
// Request-Informationen
case REQUEST_METHOD = 'REQUEST_METHOD';
case REQUEST_URI = 'REQUEST_URI';
case QUERY_STRING = 'QUERY_STRING';
case REQUEST_SCHEME = 'REQUEST_SCHEME';
// Server-Informationen
case SERVER_NAME = 'SERVER_NAME';
case SERVER_PORT = 'SERVER_PORT';
case SERVER_PROTOCOL = 'SERVER_PROTOCOL';
case SCRIPT_NAME = 'SCRIPT_NAME';
case DOCUMENT_ROOT = 'DOCUMENT_ROOT';
// Client-Informationen
case REMOTE_ADDR = 'REMOTE_ADDR';
case REMOTE_HOST = 'REMOTE_HOST';
case REMOTE_PORT = 'REMOTE_PORT';
case REMOTE_USER = 'REMOTE_USER';
// HTTP-Header (als SERVER-Variablen)
case HTTP_HOST = 'HTTP_HOST';
case HTTP_USER_AGENT = 'HTTP_USER_AGENT';
case HTTP_ACCEPT = 'HTTP_ACCEPT';
case HTTP_ACCEPT_LANGUAGE = 'HTTP_ACCEPT_LANGUAGE';
case HTTP_ACCEPT_ENCODING = 'HTTP_ACCEPT_ENCODING';
case HTTP_CONNECTION = 'HTTP_CONNECTION';
case HTTP_REFERER = 'HTTP_REFERER';
case HTTP_AUTHORIZATION = 'HTTP_AUTHORIZATION';
case HTTP_X_FORWARDED_FOR = 'HTTP_X_FORWARDED_FOR';
case HTTP_X_FORWARDED_PROTO = 'HTTP_X_FORWARDED_PROTO';
case HTTP_X_REAL_IP = 'HTTP_X_REAL_IP';
// HTTPS und Sicherheit
case HTTPS = 'HTTPS';
case SSL_PROTOCOL = 'SSL_PROTOCOL';
// CGI/FastCGI-spezifisch
case GATEWAY_INTERFACE = 'GATEWAY_INTERFACE';
case SERVER_SOFTWARE = 'SERVER_SOFTWARE';
// Pfad-Informationen
case PATH_INFO = 'PATH_INFO';
case PATH_TRANSLATED = 'PATH_TRANSLATED';
// Content-Informationen
case CONTENT_LENGTH = 'CONTENT_LENGTH';
case CONTENT_TYPE = 'CONTENT_TYPE';
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Framework\Http;
enum ServerProtocol: string
{
case HTTP_1_0 = "HTTP/1.0";
case HTTP_1_1 = "HTTP/1.1";
case HTTP_2_0 = "HTTP/2.0";
}

View File

@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Session;
final readonly class CsrfProtection
{
private const int TOKEN_LIFETIME = 3600; //1h
private const int MAX_TOKENS_PER_FORM = 3;
private Session $session;
public function __construct(Session $session)
{
$this->session = $session;
// Sicherstellen, dass CSRF-Bereich existiert
if (!$this->session->has(SessionKey::CSRF->value)) {
$this->session->set(SessionKey::CSRF->value, []);
}
}
public function generateToken(string $formId): string
{
$token = bin2hex(random_bytes(32));
$csrf = $this->session->get(SessionKey::CSRF->value, []);
if (!isset($csrf[$formId])) {
$csrf[$formId] = [];
}
$csrf[$formId][] = [
'token' => $token,
'created_at' => time()
];
$csrf[$formId] = $this->cleanupOldTokens($csrf[$formId]);
$this->session->set(SessionKey::CSRF->value, $csrf);
return $token;
}
public function validateToken(string $formId, string $token): bool
{
$csrf = $this->session->get(SessionKey::CSRF->value, []);
if (!isset($csrf[$formId])) {
return false;
}
foreach ($csrf[$formId] as $index => $tokenData) {
if ($tokenData['token'] === $token) {
// Token ist gültig - NICHT löschen, nur als "used" markieren
$csrf[$formId][$index]['used_at'] = time();
$this->session->set(SessionKey::CSRF->value, $csrf);
return true;
}
}
return false;
}
private function cleanupOldTokens(array $tokens): array
{
$now = time();
$cleaned = [];
foreach ($tokens as $tokenData) {
// Behalte Token die:
// 1. Noch nicht abgelaufen sind
// 2. Kürzlich verwendet wurden (für Re-Submits)
$age = $now - $tokenData['created_at'];
$usedRecently = isset($tokenData['used_at']) &&
($now - $tokenData['used_at']) < 300; // 5 Minuten
if ($age < self::TOKEN_LIFETIME || $usedRecently) {
$cleaned[] = $tokenData;
}
}
// Behalte nur die neuesten N Tokens
return array_slice($cleaned, -self::MAX_TOKENS_PER_FORM);
}
// Diese Methode nur für spezielle Fälle behalten (z.B. AJAX)
/*public function renderHiddenFields(string $formId): string
{
$token = $this->generateToken($formId);
return sprintf(
'<input type="hidden" name="_token" value="%s"><input type="hidden" name="_form_id" value="%s">',
htmlspecialchars($token),
htmlspecialchars($formId)
);
}*/
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Framework\Http\Session\Events;
use App\Framework\Http\Session\SessionId;
final readonly class CsrfTokenGeneratedEvent
{
public function __construct(
public SessionId $sessionId,
public string $formId,
public string $token,
public int $timestamp
){}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Framework\Http\Session\Events;
use App\Framework\Http\Session\SessionId;
final readonly class CsrfTokenValidatedEvent
{
public function __construct(
public SessionId $sessionId,
public string $formId,
public string $token,
public bool $isValid,
public int $timestamp
){}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Framework\Http\Session\Events;
use App\Framework\Http\Session\SessionId;
final readonly class FlashMessageAddedEvent
{
public function __construct(
public SessionId $sessionId,
public string $type,
public string $message,
public int $timestamp
){}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Framework\Http\Session\Events;
use App\Framework\Http\Session\SessionId;
final readonly class FlashMessageRemovedEvent
{
public function __construct(
public SessionId $sessionId,
public string $type,
public string $message,
public int $timestamp
){}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Framework\Http\Session\Events;
use App\Framework\Http\Session\SessionId;
final readonly class SessionClearedEvent
{
public function __construct(
public SessionId $sessionId,
public array $oldData,
public int $timestamp
){}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Framework\Http\Session\Events;
use App\Framework\Http\Session\SessionId;
final readonly class SessionDataChangedEvent
{
public function __construct(
public SessionId $sessionId,
public string $key,
public mixed $oldValue,
public mixed $newValue,
public int $timestamp
){}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Framework\Http\Session\Events;
use App\Framework\Http\Session\SessionId;
final readonly class SessionDataRemovedEvent
{
public function __construct(
public SessionId $sessionId,
public string $key,
public mixed $oldValue,
public int $timestamp
){}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Framework\Http\Session\Events;
use App\Framework\Http\Session\SessionId;
final readonly class SessionInvalidatedEvent
{
public function __construct(
public SessionId $sessionId,
public string $reason,
public array $securityData,
public int $timestamp
){}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Framework\Http\Session\Events;
use App\Framework\Http\Session\SessionId;
final readonly class SessionRegeneratedEvent
{
public function __construct(
public SessionId $sessionId,
public SessionId $oldSessionId,
public string $reason,
public int $timestamp
){}
}

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Session;
/**
* The FlashBag class provides a mechanism to manage flash messages in a session,
* typically used for temporary notifications such as success messages, error alerts,
* or other transient data between requests.
*/
final class FlashBag
{
private array $newFlashData = [];
public function __construct(
private Session $session
){
$this->session = $session;
// Sicherstellen, dass Flash-Bereich existiert
if (!$this->session->has(SessionKey::FLASH->value)) {
$this->session->set(SessionKey::FLASH->value, []);
}
}
public function add(string $type, string $message): void
{
$flashData = $this->session->get(SessionKey::FLASH->value, []);
if (!isset($flashData[$type])) {
$flashData[$type] = [];
}
$flashData[$type][] = $message;
$this->session->set(SessionKey::FLASH->value, $flashData);
}
public function get(string $type): array
{
$flashData = $this->session->get(SessionKey::FLASH->value, []);
$messages = $flashData[$type] ?? [];
// Nur für diesen spezifischen Typ löschen
if (isset($flashData[$type])) {
unset($flashData[$type]);
$this->session->set(SessionKey::FLASH->value, $flashData);
}
return $messages;
}
public function has(string $type): bool
{
$flashData = $this->session->get(SessionKey::FLASH->value, []);
return !empty($flashData[$type]);
}
public function all(): array
{
$flashData = $this->session->get(SessionKey::FLASH->value, []);
$this->session->set(SessionKey::FLASH->value, []);
return $flashData;
}
public function keep(string $type): void
{
if ($this->has($type)) {
$this->newFlashData[$type] = $this->session->get(SessionKey::FLASH->value)[$type];
}
}
public function keepAll(): void
{
$this->newFlashData = $this->session->get(SessionKey::FLASH->value, []);
}
public function age(): void
{
$this->session->set(SessionKey::FLASH->value, $this->newFlashData);
$this->newFlashData = [];
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Session;
/**
* This class provides functionality to store, retrieve, and manage form data
* in a session-based storage.
*/
final readonly class FormDataStorage
{
public function __construct(
private Session $session
){
// Sicherstellen, dass Formulardatenbereich existiert
if (!$this->session->has(SessionKey::FORM_DATA->value)) {
$this->session->set(SessionKey::FORM_DATA->value, []);
}
}
public function store(string $formId, array $data): void
{
$formData = $this->session->get(SessionKey::FORM_DATA->value, []);
$formData[$formId] = $data;
$this->session->set(SessionKey::FORM_DATA->value, $formData);
}
public function get(string $formId): array
{
$formData = $this->session->get(SessionKey::FORM_DATA->value, []);
return $formData[$formId] ?? [];
}
public function getField(string $formId, string $field, $default = null)
{
$data = $this->get($formId);
return $data[$field] ?? $default;
}
public function has(string $formId): bool
{
$formData = $this->session->get(SessionKey::FORM_DATA->value, []);
return !empty($formData[$formId]);
}
public function clear(string $formId): void
{
$formData = $this->session->get(SessionKey::FORM_DATA->value, []);
if (isset($formData[$formId])) {
unset($formData[$formId]);
$this->session->set(SessionKey::FORM_DATA->value, $formData);
}
}
public function clearAll(): void
{
$this->session->set(SessionKey::FORM_DATA->value, []);
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Session;
final readonly class RandomBytesSessionIdGenerator implements SessionIdGenerator
{
public function __construct(
private int $length = 32
) {
}
public function generate(): SessionId
{
return SessionId::fromString(bin2hex(random_bytes($this->length)));
}
public function isValid(SessionId $id): bool
{
$idString = $id->toString();
return strlen($idString) === $this->length * 2 && ctype_xdigit($idString);
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Session;
use Predis\Client;
/**
* Implementation of the SessionStorage interface using Redis as the storage backend.
* This class provides methods to read, write, remove, and migrate session data stored in Redis.
*/
final class RedisSessionStorage implements SessionStorage
{
private const string PREFIX = 'session:';
public function __construct(
private readonly Client $redis = new Client([
'scheme' => 'tcp',
'host' => 'redis',
'port' => 6379,
'parameters' => []
]),
private readonly int $ttl = 3600 // 1 Stunde standardmäßig
) {
#$this->redis->connect();
}
public function read(SessionId $id): array
{
$data = $this->redis->get($this->getKey($id));
if ($data === null) {
return [];
}
return json_decode($data, true) ?? [];
}
public function write(SessionId $id, array $data): void
{
$this->redis->setex(
$this->getKey($id),
$this->ttl,
json_encode($data)
);
}
public function remove(SessionId $id): void
{
$this->redis->del($this->getKey($id));
}
public function migrate(SessionId $fromId, SessionId $toId): void
{
$data = $this->read($fromId);
$this->write($toId, $data);
$this->remove($fromId);
}
private function getKey(SessionId $id): string
{
return self::PREFIX . $id->toString();
}
}

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Session;
use App\Framework\DateTime\Clock;
use App\Framework\Http\IpAddress;
final readonly class SecurityManager
{
public function __construct(
// Muss Privat bleiben: Der SecurityManager ist teil einer übergeordneten Session
private Session $session,
private Clock $clock,
public int $sessionTimeout = 3600,
public int $sessionRegenerationInterval = 300
) {}
private function key(): string
{
return SessionKey::SECURITY->value;
}
public function initialize(string $userAgent, IpAddress $ipAddress): void
{
$securityData = new SessionSecurityData(
$userAgent,
$ipAddress,
$this->clock->time(),
$this->clock->time()
);
$this->session->set($this->key(), $securityData->toArray());
}
public function updateLastActivity(): void
{
if ($this->session->has($this->key())) {
$securityData = SessionSecurityData::fromSession($this->session);
$updatedData = $securityData->withLastActivity($this->clock->time());
$this->session->set($this->key(), $updatedData->toArray());
}
}
public function shouldRegenerateId(): bool
{
if (!$this->session->has($this->key())) {
return true;
}
$securityData = SessionSecurityData::fromSession($this->session);
return ($this->clock->time() - $securityData->lastRegeneration) > $this->sessionRegenerationInterval;
}
public function markRegenerated(): void
{
if ($this->session->has($this->key())) {
$securityData = SessionSecurityData::fromSession($this->session);
$updatedData = $securityData->withLastRegeneration($this->clock->time());
$this->session->set($this->key(), $updatedData->toArray());
}
}
public function validate(string $userAgent, IpAddress $ipAddress): bool
{
if (!$this->session->has($this->key())) {
return false;
}
$securityData = SessionSecurityData::fromSession($this->session);
// UserAgent muss übereinstimmen
if ($securityData->userAgent !== $userAgent) {
return false;
}
// IP-Adressen-Prüfung
if ($securityData->ipAddress->value !== $ipAddress->value) {
return false;
}
// Timeout-Prüfung
if (($this->clock->time() - $securityData->lastActivity) > $this->sessionTimeout) {
return false;
}
return true;
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace App\Framework\Http\Session;
use App\Framework\Attributes\Singleton;
use App\Framework\DateTime\Clock;
final class Session
{
private array $data = [];
#private bool $isStarted = false;
// Komponenten als Properties
public readonly FlashBag $flash;
public readonly ValidationErrorBag $validation;
public readonly FormDataStorage $form;
public readonly SecurityManager $security;
public readonly CsrfProtection $csrf;
public function __construct(
public readonly SessionId $id,
private readonly Clock $clock,
){
#$this->isStarted = true;
// Komponenten initialisieren
$this->flash = new FlashBag($this);
$this->validation = new ValidationErrorBag($this);
$this->form = new FormDataStorage($this);
$this->security = new SecurityManager($this, $this->clock);
$this->csrf = new CsrfProtection($this);
}
public function isStarted(): bool
{
return true;
#return $this->isStarted;
}
public function get(string $key, $default = null): mixed
{
return $this->data[$key] ?? $default;
}
public function set(string $key, $value): void
{
$this->data[$key] = $value;
}
public function has(string $key): bool
{
return array_key_exists($key, $this->data);
}
public function remove(string $key): void
{
unset($this->data[$key]);
}
public function all(): array
{
return $this->data;
}
public function clear(): void
{
$this->data = [];
}
public function fromArray(array $data): void
{
$this->data = $data;
}
public function __debugInfo(): array
{
return [$this->id->toString(), ...$this->data];
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Session;
use InvalidArgumentException;
final readonly class SessionId
{
private function __construct(private string $value)
{
$this->validate($value);
}
public static function fromString(string $value): self
{
return new self($value);
}
public function toString(): string
{
return $this->value;
}
public function __toString(): string
{
return $this->toString();
}
public function equals(SessionId $other): bool
{
return $this->value === $other->value;
}
private function validate(string $value): void
{
if (empty($value)) {
throw new InvalidArgumentException('SessionId darf nicht leer sein');
}
// Weitere Validierungen je nach Anforderungen
// z.B. Mindestlänge, erlaubte Zeichen, Format
if (strlen($value) < 32) {
throw new InvalidArgumentException('SessionId muss mindestens 32 Zeichen lang sein');
}
if (!ctype_alnum($value)) {
throw new InvalidArgumentException('SessionId darf nur alphanumerische Zeichen enthalten');
}
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Session;
interface SessionIdGenerator
{
public function generate(): SessionId;
public function isValid(SessionId $id): bool;
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Session;
use App\Framework\DateTime\Clock;
use App\Framework\DI\Container;
use App\Framework\DI\Initializer;
use App\Framework\Http\ResponseManipulator;
final readonly class SessionInitializer
{
public function __construct(
private Container $container
) {}
/**
* Initialisiert die Session-Komponenten im Container
*/
#[Initializer]
public function __invoke(): SessionManager
{
$clock = $this->container->get(Clock::class);
return new SessionManager(
new RandomBytesSessionIdGenerator(),
new ResponseManipulator(),
$clock
);
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Session;
interface SessionInterface
{
public function getId(): SessionId;
public function get(string $key, mixed $default = null): mixed;
public function set(string $key, mixed $value): void;
public function has(string $key): bool;
public function remove(string $key): void;
public function clear(): void;
public function all(): array;
public function isStarted(): bool;
public function regenerate(): void;
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Framework\Http\Session;
enum SessionKey: string
{
case FLASH = '__flash';
case FORM_DATA = '__form_data';
case VALIDATION_ERRORS = '__validation_errors';
case SECURITY = '__security';
case META = '__meta';
case CSRF = '__csrf';
}

View File

@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Session;
use App\Framework\DateTime\Clock;
use App\Framework\Http\Cookies\Cookie;
use App\Framework\Http\Cookies\Cookies;
use App\Framework\Http\Request;
use App\Framework\Http\Response;
use App\Framework\Http\ResponseManipulator;
final class SessionManager
{
private const string SESSION_COOKIE_NAME = 'ms_context';
public function __construct(
private readonly SessionIdGenerator $generator,
private readonly ResponseManipulator $responseManipulator,
private readonly Clock $clock,
private readonly array $cookieConfig = [
'path' => '/',
'domain' => null,
'secure' => true,
'httpOnly' => true,
'sameSite' => 'lax',
],
private readonly SessionStorage $storage = new RedisSessionStorage(),
) {
}
public function getOrCreateSession(Request $request): Session
{
// Session-ID aus Cookie lesen
$sessionId = $this->getSessionIdFromCookies($request->cookies);
if ($sessionId !== null) {
// Versuchen, existierende Session zu laden
$data = $this->storage->read($sessionId);
$session = new Session($sessionId, $this->clock);;
$session->fromArray($data);
if ($session !== null) {
return $session;
}
}
// Keine gültige Session gefunden - neue erstellen
return $this->createNewSession();
}
public function createNewSession(): Session
{
$sessionId = $this->generator->generate();
return new Session($sessionId, $this->clock);
}
public function regenerateSession(Session $session): Session
{
$oldData = $session->all();
// Neue Session erstellen
$newSession = $this->createNewSession();
$newSession->fromArray($oldData);
// Session-Sicherheitsdaten aktualisieren
$newSession->security->markRegenerated();
// Alte Session löschen
$this->storage->remove($session->id);
// Neue Session speichern
$this->storage->write($newSession->id, $newSession->all());
return $newSession;
}
public function saveSession(Session $session, Response $response): Response
{
$this->saveSessionData($session);
// Setze das Session-Cookie
$cookie = new Cookie(
self::SESSION_COOKIE_NAME,
$session->id->toString(),
0, // 0 = Browser-Session
$this->cookieConfig['path'],
$this->cookieConfig['domain'],
$this->cookieConfig['secure'],
$this->cookieConfig['httpOnly'],
$this->cookieConfig['sameSite']
);
return $this->responseManipulator->withHeader(
$response,
'Set-Cookie',
$cookie->toHeaderString()
);
}
/**
* Speichert nur die Session-Daten ohne Cookie zu setzen
*/
private function saveSessionData(Session $session): void
{
$this->storage->write($session->id, $session->all());
}
private function getSessionIdFromCookies(Cookies $cookies): ?SessionId
{
$sessionCookie = $cookies->get(self::SESSION_COOKIE_NAME);
if ($sessionCookie === null) {
return null;
}
return SessionId::fromString($sessionCookie->value);
}
public function destroySession(Session $session, Response $response): Response
{
// Session löschen
$this->storage->remove($session->id);
// Cookie zum Löschen setzen (abgelaufenes Datum)
$cookie = new Cookie(
self::SESSION_COOKIE_NAME,
'',
1, // In der Vergangenheit
$this->cookieConfig['path'],
$this->cookieConfig['domain'],
$this->cookieConfig['secure'],
$this->cookieConfig['httpOnly'],
$this->cookieConfig['sameSite']
);
// Cookie dem Response hinzufügen
return $this->responseManipulator->withHeader($response, 'Set-Cookie', $cookie->toHeaderString());
}
public function getCookieName(): string
{
return self::SESSION_COOKIE_NAME;
}
public function getCookieConfig(): array
{
return $this->cookieConfig;
}
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Session;
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\RequestStateManager;
use App\Framework\Http\ResponseManipulator;
#[MiddlewarePriorityAttribute(MiddlewarePriority::SESSION)]
final readonly class SessionMiddleware implements HttpMiddleware
{
public function __construct(
private Container $container,
private SessionManager $sessionManager
) {}
public function __invoke(MiddlewareContext $context, callable $next, RequestStateManager $stateManager): MiddlewareContext
{
$session = $this->sessionManager->getOrCreateSession($context->request);
$userAgent = $context->request->headers->get('User-Agent')[0] ?? '';
#$ipAddress = $context->request->headers->get('REMOTE_ADDR') ?? '';
$ipAddress = $context->request->server->getClientIp();
if($session->has(SessionKey::SECURITY->value)) {
$isValid = $session->security->validate($userAgent, $ipAddress);
if (!$isValid) {
// Session ist kompromittiert - neue erstellen
$session = $this->sessionManager->createNewSession();
$session->security->initialize($userAgent, $ipAddress);
} else {
// Letzte Aktivität aktualisieren
$session->security->updateLastActivity();
// Session-ID-Rotation bei Bedarf
if ($session->security->shouldRegenerateId()) {
$session = $this->sessionManager->regenerateSession($session);
}
}
} else {
// Neue Session mit Sicherheitsdaten initialisieren
$session->security->initialize($userAgent, $ipAddress);
}
#$this->container->bind(Session::class, $session);
$this->container->forget(Session::class);
$this->container->instance(Session::class, $session);
$context = $next($context);
// Flash-Daten für den nächsten Request vorbereiten
$session->flash->age();
// Session speichern
$responseWithSession = $this->sessionManager->saveSession($session, $context->response);
return $context->withResponse($responseWithSession);
}
}

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