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