1<?php
2
3namespace dokuwiki;
4
5use dokuwiki\Extension\Event;
6
7/**
8 * Log messages to a daily log file
9 */
10class Logger
11{
12    public const LOG_ERROR = 'error';
13    public const LOG_DEPRECATED = 'deprecated';
14    public const LOG_DEBUG = 'debug';
15
16    /** @var Logger[] */
17    protected static $instances;
18
19    /** @var string what kind of log is this */
20    protected $facility;
21
22    protected $isLogging = true;
23
24    /** @var string[] a list of expected log messages, only used during unit testing */
25    protected $expected = [];
26
27    /**
28     * Logger constructor.
29     *
30     * @param string $facility The type of log
31     */
32    protected function __construct($facility)
33    {
34        global $conf;
35        $this->facility = $facility;
36
37        // Should logging be disabled for this facility?
38        $dontlog = explode(',', $conf['dontlog']);
39        $dontlog = array_map('trim', $dontlog);
40        if (in_array($facility, $dontlog)) $this->isLogging = false;
41    }
42
43    /**
44     * Return a Logger instance for the given facility
45     *
46     * @param string $facility The type of log
47     * @return Logger
48     */
49    public static function getInstance($facility = self::LOG_ERROR)
50    {
51        if (empty(self::$instances[$facility])) {
52            self::$instances[$facility] = new Logger($facility);
53        }
54        return self::$instances[$facility];
55    }
56
57    /**
58     * Convenience method to directly log to the error log
59     *
60     * @param string $message The log message
61     * @param mixed $details Any details that should be added to the log entry
62     * @param string $file A source filename if this is related to a source position
63     * @param int $line A line number for the above file
64     * @return bool has a log been written?
65     */
66    public static function error($message, $details = null, $file = '', $line = 0)
67    {
68        return self::getInstance(self::LOG_ERROR)->log(
69            $message,
70            $details,
71            $file,
72            $line
73        );
74    }
75
76    /**
77     * Convenience method to directly log to the debug log
78     *
79     * @param string $message The log message
80     * @param mixed $details Any details that should be added to the log entry
81     * @param string $file A source filename if this is related to a source position
82     * @param int $line A line number for the above file
83     * @return bool has a log been written?
84     */
85    public static function debug($message, $details = null, $file = '', $line = 0)
86    {
87        return self::getInstance(self::LOG_DEBUG)->log(
88            $message,
89            $details,
90            $file,
91            $line
92        );
93    }
94
95    /**
96     * Convenience method to directly log to the deprecation log
97     *
98     * @param string $message The log message
99     * @param mixed $details Any details that should be added to the log entry
100     * @param string $file A source filename if this is related to a source position
101     * @param int $line A line number for the above file
102     * @return bool has a log been written?
103     */
104    public static function deprecated($message, $details = null, $file = '', $line = 0)
105    {
106        return self::getInstance(self::LOG_DEPRECATED)->log(
107            $message,
108            $details,
109            $file,
110            $line
111        );
112    }
113
114    /**
115     * Log a message to the facility log
116     *
117     * @param string $message The log message
118     * @param mixed $details Any details that should be added to the log entry
119     * @param string $file A source filename if this is related to a source position
120     * @param int $line A line number for the above file
121     * @triggers LOGGER_DATA_FORMAT can be used to change the logged data or intercept it
122     * @return bool has a log been written?
123     */
124    public function log($message, $details = null, $file = '', $line = 0)
125    {
126        global $EVENT_HANDLER;
127        if (!$this->isLogging) return false;
128
129        $datetime = time();
130        $data = [
131            'facility' => $this->facility,
132            'datetime' => $datetime,
133            'message' => $message,
134            'details' => $details,
135            'file' => $file,
136            'line' => $line,
137            'loglines' => [],
138            'logfile' => $this->getLogfile($datetime),
139        ];
140
141        if ($EVENT_HANDLER !== null) {
142            $event = new Event('LOGGER_DATA_FORMAT', $data);
143            if ($event->advise_before()) {
144                $data['loglines'] = $this->formatLogLines($data);
145            }
146            $event->advise_after();
147        } else {
148            // The event system is not yet available, to ensure the log isn't lost even on
149            // fatal errors, the default action is executed
150            $data['loglines'] = $this->formatLogLines($data);
151        }
152
153        // only log when any data available
154        if (count($data['loglines'])) {
155            return $this->writeLogLines($data['loglines'], $data['logfile']);
156        } else {
157            return false;
158        }
159    }
160
161    /**
162     * Is this logging instace actually logging?
163     *
164     * @return bool
165     */
166    public function isLogging()
167    {
168        return $this->isLogging;
169    }
170
171    /**
172     * Tests may register log expectations
173     *
174     * @param string $log
175     * @return void
176     */
177    public function expect($log)
178    {
179        $this->expected[] = $log;
180    }
181
182    /**
183     * Formats the given data as loglines
184     *
185     * @param array $data Event data from LOGGER_DATA_FORMAT
186     * @return string[] the lines to log
187     */
188    protected function formatLogLines($data)
189    {
190        extract($data);
191
192        // details are logged indented
193        if ($details) {
194            if (!is_string($details)) {
195                $details = json_encode($details, JSON_PRETTY_PRINT);
196            }
197            $details = explode("\n", $details);
198            $loglines = array_map(static fn($line) => '  ' . $line, $details);
199        } elseif ($details) {
200            $loglines = [$details];
201        } else {
202            $loglines = [];
203        }
204
205        // datetime, fileline, message
206        $logline = date('Y-m-d H:i:s', $datetime) . "\t";
207        if ($file) {
208            $logline .= $file;
209            if ($line) $logline .= "($line)";
210        }
211        $logline .= "\t" . $message;
212        array_unshift($loglines, $logline);
213
214        return $loglines;
215    }
216
217    /**
218     * Construct the log file for the given day
219     *
220     * @param false|string|int $date Date to access, false for today
221     * @return string
222     */
223    public function getLogfile($date = false)
224    {
225        global $conf;
226
227        if ($date !== null && !is_numeric($date)) {
228            $date = strtotime($date);
229        }
230        if (!$date) $date = time();
231
232        return $conf['logdir'] . '/' . $this->facility . '/' . date('Y-m-d', $date) . '.log';
233    }
234
235    /**
236     * Write the given lines to today's facility log
237     *
238     * @param string[] $lines the raw lines to append to the log
239     * @param string $logfile where to write to
240     * @return bool true if the log was written
241     */
242    protected function writeLogLines($lines, $logfile)
243    {
244        if (defined('DOKU_UNITTEST')) {
245            // our tests may expect certain log messages
246            if ($this->expected) {
247                $expected = array_shift($this->expected);
248                if (!str_contains($lines[0], $expected)) {
249                    throw new \RuntimeException(
250                        "Log expectation failed:\n" .
251                        "Expected: $expected\n" .
252                        "Actual:   {$lines[0]}"
253                    );
254                }
255            } else {
256                $stderr = fopen('php://stderr', 'w');
257                fwrite($stderr, "\n[" . $this->facility . '] ' . implode("\n", $lines) . "\n");
258                fclose($stderr);
259            }
260        }
261
262        return io_saveFile($logfile, implode("\n", $lines) . "\n", true);
263    }
264}
265