<?php declare (strict_types=1); /* * This file is part of the Monolog package. * * (c) Jordi Boggiano <j.boggiano@seld.be> * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace FSVendor\Monolog\Handler; use FSVendor\Monolog\Formatter\FormatterInterface; use FSVendor\Monolog\Formatter\LineFormatter; use FSVendor\Monolog\Utils; use FSVendor\Monolog\Logger; use function count; use function headers_list; use function stripos; use function trigger_error; use const E_USER_DEPRECATED; /** * Handler sending logs to browser's javascript console with no browser extension required * * @author Olivier Poitrey <rs@dailymotion.com> * * @phpstan-import-type FormattedRecord from AbstractProcessingHandler */ class BrowserConsoleHandler extends \FSVendor\Monolog\Handler\AbstractProcessingHandler { /** @var bool */ protected static $initialized = \false; /** @var FormattedRecord[] */ protected static $records = []; protected const FORMAT_HTML = 'html'; protected const FORMAT_JS = 'js'; protected const FORMAT_UNKNOWN = 'unknown'; /** * {@inheritDoc} * * Formatted output may contain some formatting markers to be transferred to `console.log` using the %c format. * * Example of formatted string: * * You can do [[blue text]]{color: blue} or [[green background]]{background-color: green; color: white} */ protected function getDefaultFormatter() : \FSVendor\Monolog\Formatter\FormatterInterface { return new \FSVendor\Monolog\Formatter\LineFormatter('[[%channel%]]{macro: autolabel} [[%level_name%]]{font-weight: bold} %message%'); } /** * {@inheritDoc} */ protected function write(array $record) : void { // Accumulate records static::$records[] = $record; // Register shutdown handler if not already done if (!static::$initialized) { static::$initialized = \true; $this->registerShutdownFunction(); } } /** * Convert records to javascript console commands and send it to the browser. * This method is automatically called on PHP shutdown if output is HTML or Javascript. */ public static function send() : void { $format = static::getResponseFormat(); if ($format === self::FORMAT_UNKNOWN) { return; } if (\count(static::$records)) { if ($format === self::FORMAT_HTML) { static::writeOutput('<script>' . static::generateScript() . '</script>'); } elseif ($format === self::FORMAT_JS) { static::writeOutput(static::generateScript()); } static::resetStatic(); } } public function close() : void { self::resetStatic(); } public function reset() { parent::reset(); self::resetStatic(); } /** * Forget all logged records */ public static function resetStatic() : void { static::$records = []; } /** * Wrapper for register_shutdown_function to allow overriding */ protected function registerShutdownFunction() : void { if (\PHP_SAPI !== 'cli') { \register_shutdown_function(['Monolog\\Handler\\BrowserConsoleHandler', 'send']); } } /** * Wrapper for echo to allow overriding */ protected static function writeOutput(string $str) : void { echo $str; } /** * Checks the format of the response * * If Content-Type is set to application/javascript or text/javascript -> js * If Content-Type is set to text/html, or is unset -> html * If Content-Type is anything else -> unknown * * @return string One of 'js', 'html' or 'unknown' * @phpstan-return self::FORMAT_* */ protected static function getResponseFormat() : string { // Check content type foreach (\headers_list() as $header) { if (\stripos($header, 'content-type:') === 0) { return static::getResponseFormatFromContentType($header); } } return self::FORMAT_HTML; } /** * @return string One of 'js', 'html' or 'unknown' * @phpstan-return self::FORMAT_* */ protected static function getResponseFormatFromContentType(string $contentType) : string { // This handler only works with HTML and javascript outputs // text/javascript is obsolete in favour of application/javascript, but still used if (\stripos($contentType, 'application/javascript') !== \false || \stripos($contentType, 'text/javascript') !== \false) { return self::FORMAT_JS; } if (\stripos($contentType, 'text/html') !== \false) { return self::FORMAT_HTML; } return self::FORMAT_UNKNOWN; } private static function generateScript() : string { $script = []; foreach (static::$records as $record) { $context = static::dump('Context', $record['context']); $extra = static::dump('Extra', $record['extra']); if (empty($context) && empty($extra)) { $script[] = static::call_array(static::getConsoleMethodForLevel($record['level']), static::handleStyles($record['formatted'])); } else { $script = \array_merge($script, [static::call_array('groupCollapsed', static::handleStyles($record['formatted']))], $context, $extra, [static::call('groupEnd')]); } } return "(function (c) {if (c && c.groupCollapsed) {\n" . \implode("\n", $script) . "\n}})(console);"; } private static function getConsoleMethodForLevel(int $level) : string { return [\FSVendor\Monolog\Logger::DEBUG => 'debug', \FSVendor\Monolog\Logger::INFO => 'info', \FSVendor\Monolog\Logger::NOTICE => 'info', \FSVendor\Monolog\Logger::WARNING => 'warn', \FSVendor\Monolog\Logger::ERROR => 'error', \FSVendor\Monolog\Logger::CRITICAL => 'error', \FSVendor\Monolog\Logger::ALERT => 'error', \FSVendor\Monolog\Logger::EMERGENCY => 'error'][$level] ?? 'log'; } /** * @return string[] */ private static function handleStyles(string $formatted) : array { $args = []; $format = '%c' . $formatted; \preg_match_all('/\\[\\[(.*?)\\]\\]\\{([^}]*)\\}/s', $format, $matches, \PREG_OFFSET_CAPTURE | \PREG_SET_ORDER); foreach (\array_reverse($matches) as $match) { $args[] = '"font-weight: normal"'; $args[] = static::quote(static::handleCustomStyles($match[2][0], $match[1][0])); $pos = $match[0][1]; $format = \FSVendor\Monolog\Utils::substr($format, 0, $pos) . '%c' . $match[1][0] . '%c' . \FSVendor\Monolog\Utils::substr($format, $pos + \strlen($match[0][0])); } $args[] = static::quote('font-weight: normal'); $args[] = static::quote($format); return \array_reverse($args); } private static function handleCustomStyles(string $style, string $string) : string { static $colors = ['blue', 'green', 'red', 'magenta', 'orange', 'black', 'grey']; static $labels = []; $style = \preg_replace_callback('/macro\\s*:(.*?)(?:;|$)/', function (array $m) use($string, &$colors, &$labels) { if (\trim($m[1]) === 'autolabel') { // Format the string as a label with consistent auto assigned background color if (!isset($labels[$string])) { $labels[$string] = $colors[\count($labels) % \count($colors)]; } $color = $labels[$string]; return "background-color: {$color}; color: white; border-radius: 3px; padding: 0 2px 0 2px"; } return $m[1]; }, $style); if (null === $style) { $pcreErrorCode = \preg_last_error(); throw new \RuntimeException('Failed to run preg_replace_callback: ' . $pcreErrorCode . ' / ' . \FSVendor\Monolog\Utils::pcreLastErrorMessage($pcreErrorCode)); } return $style; } /** * @param mixed[] $dict * @return mixed[] */ private static function dump(string $title, array $dict) : array { $script = []; $dict = \array_filter($dict); if (empty($dict)) { return $script; } $script[] = static::call('log', static::quote('%c%s'), static::quote('font-weight: bold'), static::quote($title)); foreach ($dict as $key => $value) { $value = \json_encode($value); if (empty($value)) { $value = static::quote(''); } $script[] = static::call('log', static::quote('%s: %o'), static::quote((string) $key), $value); } return $script; } private static function quote(string $arg) : string { return '"' . \addcslashes($arg, "\"\n\\") . '"'; } /** * @param mixed $args */ private static function call(...$args) : string { $method = \array_shift($args); if (!\is_string($method)) { throw new \UnexpectedValueException('Expected the first arg to be a string, got: ' . \var_export($method, \true)); } return static::call_array($method, $args); } /** * @param mixed[] $args */ private static function call_array(string $method, array $args) : string { return 'c.' . $method . '(' . \implode(', ', $args) . ');'; } }