1<?php declare(strict_types=1);
2
3/*
4 * This file is part of the Monolog package.
5 *
6 * (c) Jordi Boggiano <j.boggiano@seld.be>
7 *
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
10 */
11
12namespace Monolog\Handler;
13
14use Monolog\Formatter\FormatterInterface;
15use Monolog\Formatter\LineFormatter;
16use Monolog\Utils;
17
18use function count;
19use function headers_list;
20use function stripos;
21use function trigger_error;
22
23use const E_USER_DEPRECATED;
24
25/**
26 * Handler sending logs to browser's javascript console with no browser extension required
27 *
28 * @author Olivier Poitrey <rs@dailymotion.com>
29 *
30 * @phpstan-import-type FormattedRecord from AbstractProcessingHandler
31 */
32class BrowserConsoleHandler extends AbstractProcessingHandler
33{
34    /** @var bool */
35    protected static $initialized = false;
36    /** @var FormattedRecord[] */
37    protected static $records = [];
38
39    protected const FORMAT_HTML = 'html';
40    protected const FORMAT_JS = 'js';
41    protected const FORMAT_UNKNOWN = 'unknown';
42
43    /**
44     * {@inheritDoc}
45     *
46     * Formatted output may contain some formatting markers to be transferred to `console.log` using the %c format.
47     *
48     * Example of formatted string:
49     *
50     *     You can do [[blue text]]{color: blue} or [[green background]]{background-color: green; color: white}
51     */
52    protected function getDefaultFormatter(): FormatterInterface
53    {
54        return new LineFormatter('[[%channel%]]{macro: autolabel} [[%level_name%]]{font-weight: bold} %message%');
55    }
56
57    /**
58     * {@inheritDoc}
59     */
60    protected function write(array $record): void
61    {
62        // Accumulate records
63        static::$records[] = $record;
64
65        // Register shutdown handler if not already done
66        if (!static::$initialized) {
67            static::$initialized = true;
68            $this->registerShutdownFunction();
69        }
70    }
71
72    /**
73     * Convert records to javascript console commands and send it to the browser.
74     * This method is automatically called on PHP shutdown if output is HTML or Javascript.
75     */
76    public static function send(): void
77    {
78        $format = static::getResponseFormat();
79        if ($format === self::FORMAT_UNKNOWN) {
80            return;
81        }
82
83        if (count(static::$records)) {
84            if ($format === self::FORMAT_HTML) {
85                static::writeOutput('<script>' . static::generateScript() . '</script>');
86            } elseif ($format === self::FORMAT_JS) {
87                static::writeOutput(static::generateScript());
88            }
89            static::resetStatic();
90        }
91    }
92
93    public function close(): void
94    {
95        self::resetStatic();
96    }
97
98    public function reset()
99    {
100        parent::reset();
101
102        self::resetStatic();
103    }
104
105    /**
106     * Forget all logged records
107     */
108    public static function resetStatic(): void
109    {
110        static::$records = [];
111    }
112
113    /**
114     * Wrapper for register_shutdown_function to allow overriding
115     */
116    protected function registerShutdownFunction(): void
117    {
118        if (PHP_SAPI !== 'cli') {
119            register_shutdown_function(['Monolog\Handler\BrowserConsoleHandler', 'send']);
120        }
121    }
122
123    /**
124     * Wrapper for echo to allow overriding
125     */
126    protected static function writeOutput(string $str): void
127    {
128        echo $str;
129    }
130
131    /**
132     * Checks the format of the response
133     *
134     * If Content-Type is set to application/javascript or text/javascript -> js
135     * If Content-Type is set to text/html, or is unset -> html
136     * If Content-Type is anything else -> unknown
137     *
138     * @return string One of 'js', 'html' or 'unknown'
139     * @phpstan-return self::FORMAT_*
140     */
141    protected static function getResponseFormat(): string
142    {
143        // Check content type
144        foreach (headers_list() as $header) {
145            if (stripos($header, 'content-type:') === 0) {
146                return static::getResponseFormatFromContentType($header);
147            }
148        }
149
150        return self::FORMAT_HTML;
151    }
152
153    /**
154     * @return string One of 'js', 'html' or 'unknown'
155     * @phpstan-return self::FORMAT_*
156     */
157    protected static function getResponseFormatFromContentType(string $contentType): string
158    {
159        // This handler only works with HTML and javascript outputs
160        // text/javascript is obsolete in favour of application/javascript, but still used
161        if (stripos($contentType, 'application/javascript') !== false || stripos($contentType, 'text/javascript') !== false) {
162            return self::FORMAT_JS;
163        }
164
165        if (stripos($contentType, 'text/html') !== false) {
166            return self::FORMAT_HTML;
167        }
168
169        return self::FORMAT_UNKNOWN;
170    }
171
172    private static function generateScript(): string
173    {
174        $script = [];
175        foreach (static::$records as $record) {
176            $context = static::dump('Context', $record['context']);
177            $extra = static::dump('Extra', $record['extra']);
178
179            if (empty($context) && empty($extra)) {
180                $script[] = static::call_array('log', static::handleStyles($record['formatted']));
181            } else {
182                $script = array_merge(
183                    $script,
184                    [static::call_array('groupCollapsed', static::handleStyles($record['formatted']))],
185                    $context,
186                    $extra,
187                    [static::call('groupEnd')]
188                );
189            }
190        }
191
192        return "(function (c) {if (c && c.groupCollapsed) {\n" . implode("\n", $script) . "\n}})(console);";
193    }
194
195    /**
196     * @return string[]
197     */
198    private static function handleStyles(string $formatted): array
199    {
200        $args = [];
201        $format = '%c' . $formatted;
202        preg_match_all('/\[\[(.*?)\]\]\{([^}]*)\}/s', $format, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER);
203
204        foreach (array_reverse($matches) as $match) {
205            $args[] = '"font-weight: normal"';
206            $args[] = static::quote(static::handleCustomStyles($match[2][0], $match[1][0]));
207
208            $pos = $match[0][1];
209            $format = Utils::substr($format, 0, $pos) . '%c' . $match[1][0] . '%c' . Utils::substr($format, $pos + strlen($match[0][0]));
210        }
211
212        $args[] = static::quote('font-weight: normal');
213        $args[] = static::quote($format);
214
215        return array_reverse($args);
216    }
217
218    private static function handleCustomStyles(string $style, string $string): string
219    {
220        static $colors = ['blue', 'green', 'red', 'magenta', 'orange', 'black', 'grey'];
221        static $labels = [];
222
223        $style = preg_replace_callback('/macro\s*:(.*?)(?:;|$)/', function (array $m) use ($string, &$colors, &$labels) {
224            if (trim($m[1]) === 'autolabel') {
225                // Format the string as a label with consistent auto assigned background color
226                if (!isset($labels[$string])) {
227                    $labels[$string] = $colors[count($labels) % count($colors)];
228                }
229                $color = $labels[$string];
230
231                return "background-color: $color; color: white; border-radius: 3px; padding: 0 2px 0 2px";
232            }
233
234            return $m[1];
235        }, $style);
236
237        if (null === $style) {
238            $pcreErrorCode = preg_last_error();
239            throw new \RuntimeException('Failed to run preg_replace_callback: ' . $pcreErrorCode . ' / ' . Utils::pcreLastErrorMessage($pcreErrorCode));
240        }
241
242        return $style;
243    }
244
245    /**
246     * @param  mixed[] $dict
247     * @return mixed[]
248     */
249    private static function dump(string $title, array $dict): array
250    {
251        $script = [];
252        $dict = array_filter($dict);
253        if (empty($dict)) {
254            return $script;
255        }
256        $script[] = static::call('log', static::quote('%c%s'), static::quote('font-weight: bold'), static::quote($title));
257        foreach ($dict as $key => $value) {
258            $value = json_encode($value);
259            if (empty($value)) {
260                $value = static::quote('');
261            }
262            $script[] = static::call('log', static::quote('%s: %o'), static::quote((string) $key), $value);
263        }
264
265        return $script;
266    }
267
268    private static function quote(string $arg): string
269    {
270        return '"' . addcslashes($arg, "\"\n\\") . '"';
271    }
272
273    /**
274     * @param mixed $args
275     */
276    private static function call(...$args): string
277    {
278        $method = array_shift($args);
279        if (!is_string($method)) {
280            throw new \UnexpectedValueException('Expected the first arg to be a string, got: '.var_export($method, true));
281        }
282
283        return static::call_array($method, $args);
284    }
285
286    /**
287     * @param mixed[] $args
288     */
289    private static function call_array(string $method, array $args): string
290    {
291        return 'c.' . $method . '(' . implode(', ', $args) . ');';
292    }
293}
294