$loggingConfig Teilkonfiguration "logging" aus config.php */ public function __construct(array $loggingConfig) { $baseDir = $loggingConfig['log_dir'] ?? (__DIR__ . '/../../../public/logs'); $this->logDir = rtrim((string)$baseDir, DIRECTORY_SEPARATOR); } /** * @return array */ public function listLogFiles(): array { $result = []; if (is_dir($this->logDir) === false) { return $result; } $entries = @scandir($this->logDir); if ($entries === false) { return $result; } foreach ($entries as $name) { if ($name === '.' || $name === '..') { continue; } // Nur normale Dateien, keine Unterordner. $fullPath = $this->logDir . DIRECTORY_SEPARATOR . $name; if (is_file($fullPath) === false) { continue; } // Safety: nur "logartige" Dateien anzeigen. $lower = strtolower($name); if ( str_ends_with($lower, '.log') === false && str_ends_with($lower, '.txt') === false ) { continue; } $size = @filesize($fullPath); $mtime = @filemtime($fullPath); $result[] = [ 'name' => $name, 'size' => is_int($size) ? $size : 0, 'mtime' => is_int($mtime) ? $mtime : 0, ]; } // Neueste zuerst usort( $result, static function (array $a, array $b): int { return ($b['mtime'] ?? 0) <=> ($a['mtime'] ?? 0); } ); return $result; } /** * Liefert Metadaten zur ausgewählten Datei (oder null wenn ungültig). * * @return array{name:string, size:int, mtime:int}|null */ public function getFileMeta(string $fileName): ?array { $path = $this->resolveLogFilePath($fileName); if ($path === null) { return null; } $size = @filesize($path); $mtime = @filemtime($path); return [ 'name' => basename($path), 'size' => is_int($size) ? $size : 0, 'mtime' => is_int($mtime) ? $mtime : 0, ]; } /** * @param string $fileName * @param int $maxLines * @param string|null $levelFilter z.B. "ERROR"|"WARNING"|"INFO"|"DEBUG"|null * @param string|null $search Freitextsuche in message/context/raw * * @return array|null, * raw:string * }> */ public function getEntries(string $fileName, int $maxLines = 200, ?string $levelFilter = null, ?string $search = null): array { $path = $this->resolveLogFilePath($fileName); if ($path === null) { return []; } $lines = $this->tailLines($path, $maxLines); $entries = []; foreach ($lines as $line) { $parsed = $this->parseLine($line); if ($levelFilter !== null && $levelFilter !== '') { $lvl = strtoupper((string)($parsed['level'] ?? '')); if ($lvl !== strtoupper($levelFilter)) { continue; } } if ($search !== null && $search !== '') { $haystack = $parsed['raw'] . ' ' . $parsed['message']; if (is_array($parsed['context'])) { $haystack .= ' ' . (string)json_encode($parsed['context'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } if (mb_stripos($haystack, $search) === false) { continue; } } $entries[] = $parsed; } return $entries; } /** * @return string|null Vollständiger Pfad oder null, wenn ungültig/Traversal */ private function resolveLogFilePath(string $fileName): ?string { $fileName = trim($fileName); if ($fileName === '') { return null; } // Keine Pfade erlauben, nur Dateiname $fileName = basename($fileName); $candidate = $this->logDir . DIRECTORY_SEPARATOR . $fileName; $realDir = realpath($this->logDir); $realFile = realpath($candidate); if ($realDir === false || $realFile === false) { return null; } // Muss innerhalb des Log-Verzeichnisses liegen $realDirNorm = rtrim($realDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; $realFileNorm = $realFile; if (str_starts_with($realFileNorm, $realDirNorm) === false) { return null; } if (is_file($realFileNorm) === false) { return null; } return $realFileNorm; } /** * Tail-Implementierung: liest die letzten $maxLines Zeilen. * * @return array */ private function tailLines(string $filePath, int $maxLines): array { $maxLines = max(1, min(2000, $maxLines)); $fh = @fopen($filePath, 'rb'); if ($fh === false) { return []; } $chunkSize = 8192; $buffer = ''; $pos = -1; $linesFound = 0; // ans Ende springen @fseek($fh, 0, SEEK_END); $fileSize = (int)@ftell($fh); if ($fileSize <= 0) { @fclose($fh); return []; } while ($linesFound <= $maxLines && -$pos < $fileSize) { $readSize = $chunkSize; if (-$pos + $chunkSize > $fileSize) { $readSize = $fileSize - (-$pos); } @fseek($fh, $pos - $readSize + 1, SEEK_END); $chunk = (string)@fread($fh, $readSize); $buffer = $chunk . $buffer; $linesFound = substr_count($buffer, "\n"); $pos -= $readSize; } @fclose($fh); $lines = preg_split("/\r\n|\n|\r/", $buffer); if (is_array($lines) === false) { return []; } // ggf. letzte leere Zeile raus if (end($lines) === '') { array_pop($lines); } // letzte maxLines $lines = array_slice($lines, -$maxLines); // Leere Zeilen am Anfang/Ende tolerieren, aber nicht komplett aufblasen $clean = []; foreach ($lines as $line) { $line = (string)$line; if ($line === '') { continue; } $clean[] = $line; } return $clean; } /** * @return array{ * ts:string|null, * level:string|null, * message:string, * context:array|null, * raw:string * } */ private function parseLine(string $line): array { $raw = $line; $ts = null; $level = null; $message = $line; $context = null; // Basis: [timestamp] LEVEL ... if (preg_match('/^\[(?[0-9\-:\s]{19})\]\s+(?[A-Z]+)\s+(?.*)$/', $line, $m) === 1) { $ts = (string)$m['ts']; $level = (string)$m['lvl']; $rest = (string)$m['rest']; // Versuch: Context ist am Ende ein JSON-Objekt, das mit "{" beginnt und mit "}" endet. $ctxCandidate = null; $lastBracePos = strrpos($rest, '{'); if ($lastBracePos !== false) { $maybeJson = substr($rest, $lastBracePos); $maybeJson = trim($maybeJson); if ($maybeJson !== '' && str_starts_with($maybeJson, '{') && str_ends_with($maybeJson, '}')) { $decoded = json_decode($maybeJson, true); if (is_array($decoded)) { $ctxCandidate = $decoded; $rest = trim(substr($rest, 0, $lastBracePos)); } } } $context = $ctxCandidate; $message = $rest; } return [ 'ts' => $ts, 'level' => $level, 'message' => $message, 'context' => $context, 'raw' => $raw, ]; } }