*/ private array $allowedExtensions = [ 'jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'svg', 'css', 'js', 'woff', 'woff2', 'ttf', 'eot', 'pdf', 'ico', 'xml', 'json', ]; /** @var array */ 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 readonly PathProvider $pathProvider, private readonly Clock $clock, private readonly Storage $storage, private readonly string $mediaPrefix = '/media' ) { } public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext { $request = $context->request; $path = $request->path; // Prüfen ob es sich um eine Media-Anfrage handelt if (str_starts_with($path, $this->mediaPrefix)) { $filePath = substr($path, strlen($this->mediaPrefix)); $response = $this->serveStaticFile($filePath); return new MiddlewareContext($request, $response); } return $next($context); } 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 (! $this->storage->exists($fullPath)) { return $this->notFound(); } // MIME-Typ ermitteln $mimeType = $this->mimeTypes[$extension] ?? 'application/octet-stream'; // Storage verwenden $content = $this->storage->get($fullPath); // Cache-Control Header basierend auf Dateityp setzen $cacheControl = $this->getCacheControlHeader($extension); // ETag für Caching generieren $etag = '"' . md5($content) . '"'; // Last-Modified Header mit Clock $lastModified = $this->clock->now() ->format('D, d M Y H:i:s') . ' GMT'; // Headers zusammenstellen $headers = new Headers([ 'Content-Type' => $mimeType, 'Content-Length' => (string) strlen($content), '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) ?? $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' ); } }