. * * @copyright (c) Visman * @license The MIT License (MIT) */ declare(strict_types=1); namespace ForkBB\Core; use Psr\Log\LoggerInterface; use Psr\Log\LogLevel; use Psr\Log\InvalidArgumentException; use DateTimeZone; use DateTime; use RuntimeException; use Throwable; class Log implements LoggerInterface { const JSON_OPTIONS = \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR; protected $path; protected $lineFormat; protected $timeFormat; protected $resource; protected $hidePath; public function __construct(array $config) { $this->path = $config['path'] ?? __DIR__ . '/../log/{Y-m-d}.log'; $this->lineFormat = $config['lineFormat'] ?? "%datetime% [%level_name%] %message%\t%context%\n"; $this->timeFormat = $config['timeFormat'] ?? 'Y-m-d H:i:s'; $this->hidePath = \realpath(__DIR__ . '/../../'); } public function __destruct() { if (\is_resource($this->resource)) { \fclose($this->resource); } } /** * Logs with an arbitrary level. * * @param mixed $level * @param string $message * @param array $context * * @return void * * @throws \Psr\Log\InvalidArgumentException */ public function log($level, $message, array $context = []) { if ( \is_object($message) && \method_exists($message, '__toString') ) { $message = (string) $message; } if (! \is_string($message)) { throw new InvalidArgumentException('Expected string in message'); } if (! \is_string($level)) { throw new InvalidArgumentException('Expected string in level'); } switch ($level) { case LogLevel::EMERGENCY: case LogLevel::ALERT: case LogLevel::CRITICAL: case LogLevel::ERROR: case LogLevel::WARNING: case LogLevel::NOTICE: case LogLevel::INFO: case LogLevel::DEBUG: break; default: throw new InvalidArgumentException('Invalid level value'); } $context = $this->contextExp($level, $context); $line = $this->generateLine($level, $message, $context); if (! \is_resource($this->resource)) { $this->initResource(); } \flock($this->resource, \LOCK_EX); \fwrite($this->resource, $line); \flock($this->resource, \LOCK_UN); } protected function contextExp(string $level, array $context): array { $ext = $context['headers'] ?? null; $headers = [ 'REMOTE_ADDR' => $_SERVER['REMOTE_ADDR'] ?? null, 'HTTP_USER_AGENT' => $_SERVER['HTTP_USER_AGENT'] ?? null, ]; if (null === $ext) { switch ($level) { case LogLevel::EMERGENCY: case LogLevel::ALERT: case LogLevel::CRITICAL: case LogLevel::ERROR: case LogLevel::WARNING: $ext = true; break; } } if (true === $ext) { foreach ($_SERVER as $key => $value) { if ( 'HTTP_' === \substr($key, 0, 5) && 'HTTP_USER_AGENT' !== $key && 'HTTP_COOKIE' !== $key ) { $headers[$key] = $value; } } } $context['headers'] = $headers; return $context; } protected function initResource(): void { $dt = new DateTime('now', new DateTimeZone('UTC')); $path = \preg_replace_callback( '%{([^{}]+)}%', function ($matches) use ($dt) { $result = $dt->format($matches[1]); return $result ?: 'bad_format'; }, $this->path ); if (! \is_writable($path)) { $dir = \pathinfo($path, \PATHINFO_DIRNAME); if ( ! \is_dir($dir) && ! \mkdir($dir, 0755, true) ) { throw new RuntimeException("Unable to create '{$dir}' directory"); } if (! \chmod($dir, 0755)) { throw new RuntimeException("Error changing the access mode to '{$dir}' directory"); } if ( \is_file($path) && ! \chmod($dir, 0755) ) { throw new RuntimeException("Error changing the access mode to '{$path}' file"); } } $this->resource = \fopen($path, 'a'); if (! \is_resource($this->resource)) { throw new RuntimeException("Could not get access to '{$path}' resource"); } } protected function generateLine(string $level, string $message, array $context): string { if ( false !== \strpos($message, '{') && false !== \strpos($message, '}') ) { $message = $this->interpolate($message, $context); } if ( isset($context['exception']) && $context['exception'] instanceof Throwable ) { $context['exception'] = \str_replace($this->hidePath, '...', (string) $context['exception']); } $dt = new DateTime('now', new DateTimeZone('UTC')); $result = [ '%datetime%' => $dt->format($this->timeFormat), '%level_name%' => $level, '%message%' => \addcslashes($message, "\0..\37\\"), '%context%' => \json_encode($context, self::JSON_OPTIONS), ]; return \strtr($this->lineFormat, $result); } /** * Interpolates context values into the message placeholders. */ protected function interpolate(string $message, array $context): string { $replace = []; foreach ($context as $key => $val) { // check that the value can be cast to string if ( ! \is_array($val) && ( ! \is_object($val) || \method_exists($val, '__toString') ) ) { $replace['{' . $key . '}'] = \str_replace($this->hidePath, '...', (string) $val); } } // interpolate replacement values into the message and return return \strtr($message, $replace); } /** * System is unusable. * * @param string $message * @param array $context * * @return void */ public function emergency($message, array $context = []) { $this->log(LogLevel::EMERGENCY, $message, $context); } /** * Action must be taken immediately. * * Example: Entire website down, database unavailable, etc. This should * trigger the SMS alerts and wake you up. * * @param string $message * @param array $context * * @return void */ public function alert($message, array $context = []) { $this->log(LogLevel::ALERT, $message, $context); } /** * Critical conditions. * * Example: Application component unavailable, unexpected exception. * * @param string $message * @param array $context * * @return void */ public function critical($message, array $context = []) { $this->log(LogLevel::CRITICAL, $message, $context); } /** * Runtime errors that do not require immediate action but should typically * be logged and monitored. * * @param string $message * @param array $context * * @return void */ public function error($message, array $context = []) { $this->log(LogLevel::ERROR, $message, $context); } /** * Exceptional occurrences that are not errors. * * Example: Use of deprecated APIs, poor use of an API, undesirable things * that are not necessarily wrong. * * @param string $message * @param array $context * * @return void */ public function warning($message, array $context = []) { $this->log(LogLevel::WARNING, $message, $context); } /** * Normal but significant events. * * @param string $message * @param array $context * * @return void */ public function notice($message, array $context = []) { $this->log(LogLevel::NOTICE, $message, $context); } /** * Interesting events. * * Example: User logs in, SQL logs. * * @param string $message * @param array $context * * @return void */ public function info($message, array $context = []) { $this->log(LogLevel::INFO, $message, $context); } /** * Detailed debug information. * * @param string $message * @param array $context * * @return void */ public function debug($message, array $context = []) { $this->log(LogLevel::DEBUG, $message, $context); } }