PHP_AdminTool_Projekt/app/Services/Logging/LogViewerService.php
2025-12-17 12:07:50 +01:00

320 lines
8.6 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Logging;
/**
* LogViewerService
*
* - Listet Log-Dateien im konfigurierten Logging-Verzeichnis.
* - Liest performant die letzten N Zeilen (Tail), ohne komplette Datei zu laden.
* - Parst das LoggingService-Format:
* "[YYYY-MM-DD HH:MM:SS] LEVEL message {json}"
*/
class LogViewerService
{
private string $logDir;
/**
* @param array<string, mixed> $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<int, array{name:string, size:int, mtime:int}>
*/
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<int, array{
* ts:string|null,
* level:string|null,
* message:string,
* context:array<string,mixed>|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<int, string>
*/
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<string,mixed>|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('/^\[(?<ts>[0-9\-:\s]{19})\]\s+(?<lvl>[A-Z]+)\s+(?<rest>.*)$/', $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,
];
}
}