| Current Path : /proc/3751963/cwd/public_html/pano.4uweb.ca/src/Model/ |
| Current File : //proc/3751963/cwd/public_html/pano.4uweb.ca/src/Model/Log.php |
<?php
/**
* @package panopticon
* @copyright Copyright (c)2023-2024 Nicholas K. Dionysopoulos / Akeeba Ltd
* @license https://www.gnu.org/licenses/agpl-3.0.txt GNU Affero General Public License, version 3 or later
*/
namespace Akeeba\Panopticon\Model;
defined('AKEEBA') || die;
use Akeeba\Panopticon\Model\Trait\FormatFilesizeTrait;
use Awf\Exception\App;
use Awf\Mvc\Model;
use Awf\Pagination\Pagination;
use DirectoryIterator;
use Exception;
use JsonException;
use Psr\Log\LogLevel;
use Throwable;
/**
* Log management model
*
* @since 1.0.0
*/
class Log extends Model
{
use FormatFilesizeTrait;
private array|null $logs = null;
/**
* Get a paginated list of the log files
*
* @return array
* @since 1.0.0
*/
public function getPaginatedLogFiles(): array
{
$limitStart = $this->getState('limitstart', 0, 'int');
$limit = $this->getState('limit', 50, 'int') ?? 50;
return array_slice($this->getFilteredLogFilesList(), $limitStart, $limit);
}
/**
* Get the log pagination object
*
* @return Pagination
* @throws App
* @since 1.0.0
*/
public function getPagination(): Pagination
{
$limitStart = $this->getState('limitstart', 0);
$limit = $this->getState('limit', 50) ?? 50;
$total = count($this->getFilteredLogFilesList());
return new Pagination($total, $limitStart, $limit, 10, $this->container);
}
/**
* Get the parsed log lines of the currently selected file.
*
* @return object[]
* @since 1.0.0
*/
public function getLogLines(?string $logFile = null): array
{
// Clamp the size in the range of 10KiB to 10MiB
$maxSize = $this->getState('size', 131072, 'int');
$maxSize = max(10240, min($maxSize, 10485760));
// Clamp the lines in the range of 20 to 10000
$maxLines = $this->getState('lines', 500, 'int');
$maxLines = max(20, min($maxLines, 10000));
// Make sure the log file exists, it is readable, and it does not traverse to a folder other than the log folder
$logFile ??= $this->getState('logfile', '');
$logPath = $this->getVerifiedLogFilePath($logFile);
if (empty($logPath))
{
return [];
}
// Return the parsed log lines
return $this->getLastLogLines($logPath, $maxSize, $maxLines);
}
/**
* Get the size of a log file
*
* @param string|null $fileName The log filename, NULL to get from the model state
*
* @return int
* @since 1.0.0
*/
public function getFilesize(?string $fileName = null): int
{
$fileName ??= $this->getState('logfile', '');
$filePath = $this->getVerifiedLogFilePath($fileName);
if (!$filePath)
{
return 0;
}
$filePath = APATH_LOG . '/' . $fileName;
try
{
$fileSize = @fileSize($filePath) ?? 0;
}
catch (Exception $e)
{
$fileSize = 0;
}
return $fileSize;
}
/**
* Returns a verified to be correct log file path given a log file name.
*
* This method performs the following checks:
* - The log file name must end in .log or .log.{number}.gz
* - It must be a filename relative to APATH_LOG
* - It must NOT end up pointing to a location outside APATH_LOG
* - The file must exist and be readable
*
* @param string|null $logFile The name of the log file, NULL to get from the model state
*
* @return string|null
* @since 1.0.0
*/
public function getVerifiedLogFilePath(?string $logFile = null): ?string
{
$logFile ??= $this->getState('logfile', '');
if (!str_ends_with($logFile, '.log') && !str_ends_with($logFile, '.gz')
&& !preg_match(
'/\.log\.\d+\.gz$/', $logFile
))
{
return null;
}
$logPath = APATH_LOG . '/' . $logFile;
if (!file_exists($logPath) || !is_readable($logPath))
{
return null;
}
$logPath = realpath($logPath);
if (dirname($logPath) !== realpath(APATH_LOG))
{
return null;
}
return $logPath;
}
/**
* Returns the filtered, non-paginated list of log files
*
* @return array
* @since 1.0.0
*/
protected function getFilteredLogFilesList(): array
{
if ($this->logs !== null)
{
return $this->logs;
}
$search = $this->getState('search');
$archived = ($this->getState('archived', 1) ?? 1) == 1;
$this->logs = $this->listLogFiles($archived);
if (!empty($search))
{
$this->logs = array_filter(
$this->logs, fn($x) => str_contains($x, $search)
);
}
$siteId = (int) $this->getState('site_id', '0');
if ($siteId > 0)
{
$this->logs = array_filter(
$this->logs, fn($x) => str_ends_with($x, '.' . $siteId . '.log') || str_contains($x, '.' . $siteId . '.log.')
);
}
return $this->logs;
}
/**
* Returns the raw list of all available log files
*
* @param bool $includeArchived Should I include archived logs as well?
*
* @return array
* @since 1.0.0
*/
private function listLogFiles(bool $includeArchived = true): array
{
$ret = [];
/** @var DirectoryIterator $file */
foreach (new DirectoryIterator(APATH_LOG) as $file)
{
if ($file->isDot() || !$file->isFile() || !in_array($file->getExtension(), ['log', 'gz']))
{
continue;
}
if (!$includeArchived && $file->getExtension() === 'gz')
{
continue;
}
$ret[] = $file->getFilename();
}
sort($ret, SORT_NATURAL);
return $ret;
}
/**
* Returns the last few lines of a log file
*
* @param string $logPath The full filesystem path of the log file to open
* @param int $maxSize The maximum size (in bytes) of log data to return
* @param int $maxLines The maximum number of log lines to return
*
* @return array<object>
* @since 1.0.0
*/
private function getLastLogLines(string $logPath, int $maxSize = 131072, int $maxLines = 500): array
{
$fp = @fopen($logPath, 'rt');
if ($fp === false)
{
return [];
}
@fseek($fp, 0, SEEK_END);
$totalSize = ftell($fp);
if ($totalSize < 10)
{
@fclose($fp);
return [];
}
$walkBack = min($totalSize, $maxSize);
fseek($fp, -$walkBack, SEEK_CUR);
$data = @fread($fp, $maxSize);
fclose($fp);
$lines = explode("\n", $data);
$lines = array_filter($lines);
// Throw away the first line, it might be incomplete
array_shift($lines);
// Parse the log lines
$lines = array_map([$this, 'parseLogLine'], $lines);
$lines = array_filter($lines);
// Limit the number of lines displayed
if (count($lines) > $maxLines)
{
$lines = array_slice($lines, -$maxLines);
}
// Returned the parsed lines
return array_reverse($lines);
}
/**
* Parses a log line into an object
*
* @param string|null $logLine The log line to parse
*
* @return null|object The parsed log line, or NULL if it has an invalid format
*/
private function parseLogLine(?string $logLine): ?object
{
if (!str_contains($logLine, '|'))
{
return null;
}
$parts = explode('|', $logLine);
if (count($parts) < 4)
{
return null;
}
try
{
$timestamp = $this->container->dateFactory($parts[0]);
}
catch (Throwable)
{
return null;
}
$logLevel = match (trim(strtolower($parts[1])))
{
'emergency' => LogLevel::EMERGENCY,
'alert' => LogLevel::ALERT,
'critical' => LogLevel::CRITICAL,
'error' => LogLevel::ERROR,
'warning' => LogLevel::WARNING,
'notice' => LogLevel::NOTICE,
'info' => LogLevel::INFO,
'debug' => LogLevel::DEBUG,
default => null
};
if (empty($logLevel))
{
return null;
}
$short = false;
$facility = $parts[2];
$message = $parts[3];
if (count($parts) < 6)
{
$short = true;
$facility = '';
$message = $parts[2];
}
$context = null;
$extra = null;
if (isset($parts[$short ? 3 : 4]))
{
try
{
$context = json_decode($parts[$short ? 3 : 4], flags: JSON_THROW_ON_ERROR);
}
catch (JsonException $e)
{
$context = null;
}
}
if (isset($parts[$short ? 4 : 5]))
{
try
{
$extra = json_decode($parts[$short ? 4 : 5], flags: JSON_THROW_ON_ERROR);
}
catch (JsonException $e)
{
$extra = null;
}
}
return (object) [
'loglevel' => $logLevel,
'timestamp' => $timestamp,
'facility' => trim($facility),
'message' => trim($message),
'context' => $context,
'extra' => $extra,
];
}
}