chore: complete update
This commit is contained in:
45
src/Framework/Http/Api/ClientLogController.php
Normal file
45
src/Framework/Http/Api/ClientLogController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
17
src/Framework/Http/Api/ClientLogRequest.php
Normal file
17
src/Framework/Http/Api/ClientLogRequest.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
8
src/Framework/Http/ControllerRequest.php
Normal file
8
src/Framework/Http/ControllerRequest.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Framework\Http;
|
||||
|
||||
interface ControllerRequest
|
||||
{
|
||||
|
||||
}
|
||||
@@ -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 = [])
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
24
src/Framework/Http/Cookies/Cookies.php
Normal file
24
src/Framework/Http/Cookies/Cookies.php
Normal 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;
|
||||
}
|
||||
}
|
||||
12
src/Framework/Http/Cookies/SameSite.php
Normal file
12
src/Framework/Http/Cookies/SameSite.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Framework\Http\Cookies;
|
||||
|
||||
enum SameSite: string
|
||||
{
|
||||
case Strict = 'Strict';
|
||||
|
||||
case Lax = 'Lax';
|
||||
|
||||
case None = 'None';
|
||||
}
|
||||
92
src/Framework/Http/DOKUMENTATION.md
Normal file
92
src/Framework/Http/DOKUMENTATION.md
Normal 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');
|
||||
```
|
||||
36
src/Framework/Http/Emitter/AdaptiveStreamEmitter.php
Normal file
36
src/Framework/Http/Emitter/AdaptiveStreamEmitter.php
Normal 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();
|
||||
}
|
||||
}
|
||||
10
src/Framework/Http/Emitter/Emitter.php
Normal file
10
src/Framework/Http/Emitter/Emitter.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Framework\Http\Emitter;
|
||||
|
||||
use App\Framework\Http\Response;
|
||||
|
||||
interface Emitter
|
||||
{
|
||||
public function emit(Response $response): void;
|
||||
}
|
||||
46
src/Framework/Http/Emitter/HttpEmitter.php
Normal file
46
src/Framework/Http/Emitter/HttpEmitter.php
Normal 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;
|
||||
}
|
||||
}
|
||||
71
src/Framework/Http/Emitter/SseEmitter.php
Normal file
71
src/Framework/Http/Emitter/SseEmitter.php
Normal 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();
|
||||
}
|
||||
}
|
||||
109
src/Framework/Http/Emitter/StreamEmitter.php
Normal file
109
src/Framework/Http/Emitter/StreamEmitter.php
Normal 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);
|
||||
}
|
||||
}
|
||||
48
src/Framework/Http/Emitter/WebSocketEmitter.php
Normal file
48
src/Framework/Http/Emitter/WebSocketEmitter.php
Normal 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);
|
||||
}
|
||||
}
|
||||
12
src/Framework/Http/Exception/HttpException.php
Normal file
12
src/Framework/Http/Exception/HttpException.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Framework\Http\Exception;
|
||||
|
||||
use App\Framework\Http\Status;
|
||||
|
||||
interface HttpException
|
||||
{
|
||||
public Status $status {
|
||||
get;
|
||||
}
|
||||
}
|
||||
16
src/Framework/Http/Exception/NotFound.php
Normal file
16
src/Framework/Http/Exception/NotFound.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
81
src/Framework/Http/HeaderKey.php
Normal file
81
src/Framework/Http/HeaderKey.php
Normal 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';
|
||||
}
|
||||
61
src/Framework/Http/HeaderManipulator.php
Normal file
61
src/Framework/Http/HeaderManipulator.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
16
src/Framework/Http/HttpMiddleware.php
Normal file
16
src/Framework/Http/HttpMiddleware.php
Normal 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;
|
||||
}
|
||||
181
src/Framework/Http/HttpMiddlewareChain.php
Normal file
181
src/Framework/Http/HttpMiddlewareChain.php
Normal 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));
|
||||
}
|
||||
}
|
||||
8
src/Framework/Http/HttpMiddlewareNext.php
Normal file
8
src/Framework/Http/HttpMiddlewareNext.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Framework\Http;
|
||||
|
||||
interface HttpMiddlewareNext
|
||||
{
|
||||
public function handle(HttpRequest $request): Response;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}*/
|
||||
) {
|
||||
}
|
||||
) {}
|
||||
}
|
||||
|
||||
27
src/Framework/Http/IpAddress.php
Normal file
27
src/Framework/Http/IpAddress.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
}
|
||||
159
src/Framework/Http/Middleware/ServeStaticFilesMiddleware.php
Normal file
159
src/Framework/Http/Middleware/ServeStaticFilesMiddleware.php
Normal 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'
|
||||
);
|
||||
}
|
||||
}
|
||||
32
src/Framework/Http/MiddlewareContext.php
Normal file
32
src/Framework/Http/MiddlewareContext.php
Normal 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;
|
||||
}
|
||||
}
|
||||
65
src/Framework/Http/MiddlewareInvoker.php
Normal file
65
src/Framework/Http/MiddlewareInvoker.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
124
src/Framework/Http/MiddlewareManager.php
Normal file
124
src/Framework/Http/MiddlewareManager.php
Normal 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;
|
||||
}
|
||||
}
|
||||
58
src/Framework/Http/MiddlewarePriority.php
Normal file
58
src/Framework/Http/MiddlewarePriority.php
Normal 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)',
|
||||
};
|
||||
}
|
||||
}
|
||||
15
src/Framework/Http/MiddlewarePriorityAttribute.php
Normal file
15
src/Framework/Http/MiddlewarePriorityAttribute.php
Normal 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
|
||||
) {}
|
||||
}
|
||||
20
src/Framework/Http/MiddlewareStateManager.php
Normal file
20
src/Framework/Http/MiddlewareStateManager.php
Normal 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);
|
||||
}
|
||||
}
|
||||
25
src/Framework/Http/Middlewares/AuthMiddleware.php
Normal file
25
src/Framework/Http/Middlewares/AuthMiddleware.php
Normal 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);
|
||||
}
|
||||
}
|
||||
53
src/Framework/Http/Middlewares/CORSMiddleware.php
Normal file
53
src/Framework/Http/Middlewares/CORSMiddleware.php
Normal 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;
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
47
src/Framework/Http/Middlewares/CsrfMiddleware.php
Normal file
47
src/Framework/Http/Middlewares/CsrfMiddleware.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
75
src/Framework/Http/Middlewares/HoneypotMiddleware.php
Normal file
75
src/Framework/Http/Middlewares/HoneypotMiddleware.php
Normal 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
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
56
src/Framework/Http/Middlewares/LoggingMiddleware.php
Normal file
56
src/Framework/Http/Middlewares/LoggingMiddleware.php
Normal 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;
|
||||
}
|
||||
}
|
||||
172
src/Framework/Http/Middlewares/RateLimitingMiddleware.php
Normal file
172
src/Framework/Http/Middlewares/RateLimitingMiddleware.php
Normal 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';
|
||||
}
|
||||
}
|
||||
46
src/Framework/Http/Middlewares/RemovePoweredByMiddleware.php
Normal file
46
src/Framework/Http/Middlewares/RemovePoweredByMiddleware.php
Normal 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;
|
||||
}
|
||||
}
|
||||
48
src/Framework/Http/Middlewares/RequestIdMiddleware.php
Normal file
48
src/Framework/Http/Middlewares/RequestIdMiddleware.php
Normal 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;
|
||||
}
|
||||
}
|
||||
59
src/Framework/Http/Middlewares/RequestLoggingMiddleware.php
Normal file
59
src/Framework/Http/Middlewares/RequestLoggingMiddleware.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
99
src/Framework/Http/Middlewares/RoutingMiddleware.php
Normal file
99
src/Framework/Http/Middlewares/RoutingMiddleware.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
48
src/Framework/Http/Middlewares/SecurityHeaderConfig.php
Normal file
48
src/Framework/Http/Middlewares/SecurityHeaderConfig.php
Normal 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=()',
|
||||
);
|
||||
}
|
||||
}
|
||||
78
src/Framework/Http/Middlewares/SecurityHeaderMiddleware.php
Normal file
78
src/Framework/Http/Middlewares/SecurityHeaderMiddleware.php
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
41
src/Framework/Http/MimeType.php
Normal file
41
src/Framework/Http/MimeType.php
Normal 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';
|
||||
|
||||
}
|
||||
37
src/Framework/Http/NamespacedState.php
Normal file
37
src/Framework/Http/NamespacedState.php
Normal 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);
|
||||
}
|
||||
}
|
||||
16
src/Framework/Http/Range.php
Normal file
16
src/Framework/Http/Range.php
Normal 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;
|
||||
}
|
||||
}
|
||||
100
src/Framework/Http/RateLimiter/RateLimiterConfig.php
Normal file
100
src/Framework/Http/RateLimiter/RateLimiterConfig.php
Normal 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;
|
||||
}
|
||||
}
|
||||
90
src/Framework/Http/RateLimiter/RedisRateLimiter.php
Normal file
90
src/Framework/Http/RateLimiter/RedisRateLimiter.php
Normal 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}";
|
||||
}
|
||||
}
|
||||
@@ -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;}
|
||||
}
|
||||
|
||||
46
src/Framework/Http/RequestBody.php
Normal file
46
src/Framework/Http/RequestBody.php
Normal 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;
|
||||
}
|
||||
}
|
||||
102
src/Framework/Http/RequestFactory.php
Normal file
102
src/Framework/Http/RequestFactory.php
Normal 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);
|
||||
}
|
||||
}
|
||||
112
src/Framework/Http/RequestId.php
Normal file
112
src/Framework/Http/RequestId.php
Normal 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();
|
||||
}
|
||||
}
|
||||
60
src/Framework/Http/RequestIdGenerator.php
Normal file
60
src/Framework/Http/RequestIdGenerator.php
Normal 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;
|
||||
}
|
||||
}
|
||||
41
src/Framework/Http/RequestStateManager.php
Normal file
41
src/Framework/Http/RequestStateManager.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
14
src/Framework/Http/ResponseType.php
Normal file
14
src/Framework/Http/ResponseType.php
Normal 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';
|
||||
}
|
||||
86
src/Framework/Http/ResponseTypeDetector.php
Normal file
86
src/Framework/Http/ResponseTypeDetector.php
Normal 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';
|
||||
}
|
||||
|
||||
}
|
||||
45
src/Framework/Http/Responses/AdaptiveStreamResponse.php
Normal file
45
src/Framework/Http/Responses/AdaptiveStreamResponse.php
Normal 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');
|
||||
}
|
||||
}
|
||||
22
src/Framework/Http/Responses/JsonResponse.php
Normal file
22
src/Framework/Http/Responses/JsonResponse.php
Normal 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);
|
||||
}
|
||||
}
|
||||
21
src/Framework/Http/Responses/MediaType.php
Normal file
21
src/Framework/Http/Responses/MediaType.php
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
20
src/Framework/Http/Responses/NotFound.php
Normal file
20
src/Framework/Http/Responses/NotFound.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
24
src/Framework/Http/Responses/RedirectResponse.php
Normal file
24
src/Framework/Http/Responses/RedirectResponse.php
Normal 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);
|
||||
}
|
||||
}
|
||||
71
src/Framework/Http/Responses/SseResponse.php
Normal file
71
src/Framework/Http/Responses/SseResponse.php
Normal 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;;
|
||||
}
|
||||
}
|
||||
29
src/Framework/Http/Responses/StreamResponse.php
Normal file
29
src/Framework/Http/Responses/StreamResponse.php
Normal 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 = '';
|
||||
}
|
||||
}
|
||||
8
src/Framework/Http/Responses/Streamable.php
Normal file
8
src/Framework/Http/Responses/Streamable.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Framework\Http\Responses;
|
||||
|
||||
interface Streamable
|
||||
{
|
||||
|
||||
}
|
||||
59
src/Framework/Http/Responses/WebSocketResponse.php
Normal file
59
src/Framework/Http/Responses/WebSocketResponse.php
Normal 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;
|
||||
}
|
||||
}
|
||||
184
src/Framework/Http/ServerEnvironment.php
Normal file
184
src/Framework/Http/ServerEnvironment.php
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
58
src/Framework/Http/ServerKey.php
Normal file
58
src/Framework/Http/ServerKey.php
Normal 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';
|
||||
}
|
||||
10
src/Framework/Http/ServerProtocol.php
Normal file
10
src/Framework/Http/ServerProtocol.php
Normal 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";
|
||||
}
|
||||
97
src/Framework/Http/Session/CsrfProtection.php
Normal file
97
src/Framework/Http/Session/CsrfProtection.php
Normal 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)
|
||||
);
|
||||
}*/
|
||||
}
|
||||
@@ -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
|
||||
){}
|
||||
}
|
||||
@@ -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
|
||||
){}
|
||||
}
|
||||
15
src/Framework/Http/Session/Events/FlashMessageAddedEvent.php
Normal file
15
src/Framework/Http/Session/Events/FlashMessageAddedEvent.php
Normal 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
|
||||
){}
|
||||
}
|
||||
@@ -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
|
||||
){}
|
||||
}
|
||||
14
src/Framework/Http/Session/Events/SessionClearedEvent.php
Normal file
14
src/Framework/Http/Session/Events/SessionClearedEvent.php
Normal 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
|
||||
){}
|
||||
}
|
||||
@@ -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
|
||||
){}
|
||||
}
|
||||
@@ -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
|
||||
){}
|
||||
}
|
||||
@@ -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
|
||||
){}
|
||||
}
|
||||
@@ -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
|
||||
){}
|
||||
}
|
||||
83
src/Framework/Http/Session/FlashBag.php
Normal file
83
src/Framework/Http/Session/FlashBag.php
Normal 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 = [];
|
||||
}
|
||||
}
|
||||
61
src/Framework/Http/Session/FormDataStorage.php
Normal file
61
src/Framework/Http/Session/FormDataStorage.php
Normal 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, []);
|
||||
}
|
||||
}
|
||||
23
src/Framework/Http/Session/RandomBytesSessionIdGenerator.php
Normal file
23
src/Framework/Http/Session/RandomBytesSessionIdGenerator.php
Normal 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);
|
||||
}
|
||||
}
|
||||
64
src/Framework/Http/Session/RedisSessionStorage.php
Normal file
64
src/Framework/Http/Session/RedisSessionStorage.php
Normal 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();
|
||||
}
|
||||
}
|
||||
94
src/Framework/Http/Session/SecurityManager.php
Normal file
94
src/Framework/Http/Session/SecurityManager.php
Normal 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;
|
||||
}
|
||||
}
|
||||
79
src/Framework/Http/Session/Session.php
Normal file
79
src/Framework/Http/Session/Session.php
Normal 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];
|
||||
}
|
||||
}
|
||||
52
src/Framework/Http/Session/SessionId.php
Normal file
52
src/Framework/Http/Session/SessionId.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
11
src/Framework/Http/Session/SessionIdGenerator.php
Normal file
11
src/Framework/Http/Session/SessionIdGenerator.php
Normal 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;
|
||||
}
|
||||
33
src/Framework/Http/Session/SessionInitializer.php
Normal file
33
src/Framework/Http/Session/SessionInitializer.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
17
src/Framework/Http/Session/SessionInterface.php
Normal file
17
src/Framework/Http/Session/SessionInterface.php
Normal 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;
|
||||
}
|
||||
13
src/Framework/Http/Session/SessionKey.php
Normal file
13
src/Framework/Http/Session/SessionKey.php
Normal 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';
|
||||
}
|
||||
156
src/Framework/Http/Session/SessionManager.php
Normal file
156
src/Framework/Http/Session/SessionManager.php
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
69
src/Framework/Http/Session/SessionMiddleware.php
Normal file
69
src/Framework/Http/Session/SessionMiddleware.php
Normal 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
Reference in New Issue
Block a user