1<?php
2
3/*
4 * This file is part of the TYPO3 CMS project.
5 *
6 * It is free software; you can redistribute it and/or modify it under
7 * the terms of the GNU General Public License, either version 2
8 * of the License, or any later version.
9 *
10 * For the full copyright and license information, please read the
11 * LICENSE.txt file that was distributed with this source code.
12 *
13 * The TYPO3 project - inspiring people to share!
14 */
15
16namespace TYPO3\CMS\Core\Log\Writer;
17
18use TYPO3\CMS\Core\Core\Environment;
19use TYPO3\CMS\Core\Log\Exception\InvalidLogWriterConfigurationException;
20use TYPO3\CMS\Core\Log\LogRecord;
21use TYPO3\CMS\Core\Utility\GeneralUtility;
22use TYPO3\CMS\Core\Utility\PathUtility;
23
24/**
25 * Log writer that writes the log records into a file.
26 */
27class FileWriter extends AbstractWriter
28{
29    /**
30     * Log file path, relative to TYPO3's base project folder
31     */
32    protected string $logFile = '';
33
34    protected string $logFileInfix = '';
35
36    /**
37     * Default log file path
38     */
39    protected string $defaultLogFileTemplate = '/log/typo3_%s.log';
40
41    /**
42     * Log file handle storage
43     *
44     * To avoid concurrent file handles on a the same file when using several FileWriter instances,
45     * we share the file handles in a static class variable
46     *
47     * @static
48     */
49    protected static array $logFileHandles = [];
50
51    /**
52     * Keep track of used file handles by different fileWriter instances
53     *
54     * As the logger gets instantiated by class name but the resources
55     * are shared via the static $logFileHandles we need to track usage
56     * of file handles to avoid closing handles that are still needed
57     * by different instances. Only if the count is zero may the file
58     * handle be closed.
59     */
60    protected static array $logFileHandlesCount = [];
61
62    /**
63     * Constructor, opens the log file handle
64     *
65     * @param array $options
66     */
67    public function __construct(array $options = [])
68    {
69        // the parent constructor reads $options and sets them
70        parent::__construct($options);
71        if (empty($options['logFile'])) {
72            $this->setLogFile($this->getDefaultLogFileName());
73        }
74    }
75
76    /**
77     * Destructor, closes the log file handle
78     */
79    public function __destruct()
80    {
81        self::$logFileHandlesCount[$this->logFile]--;
82        if (self::$logFileHandlesCount[$this->logFile] <= 0) {
83            $this->closeLogFile();
84        }
85    }
86
87    public function setLogFileInfix(string $infix)
88    {
89        $this->logFileInfix = $infix;
90    }
91
92    /**
93     * Sets the path to the log file.
94     *
95     * @param string $relativeLogFile path to the log file, relative to public web dir
96     * @return WriterInterface
97     * @throws InvalidLogWriterConfigurationException
98     */
99    public function setLogFile(string $relativeLogFile)
100    {
101        $logFile = $relativeLogFile;
102        // Skip handling if logFile is a stream resource. This is used by unit tests with vfs:// directories
103        if (!PathUtility::hasProtocolAndScheme($logFile) && !PathUtility::isAbsolutePath($logFile)) {
104            $logFile = GeneralUtility::getFileAbsFileName($logFile);
105            if (empty($logFile)) {
106                throw new InvalidLogWriterConfigurationException(
107                    'Log file path "' . $relativeLogFile . '" is not valid!',
108                    1444374805
109                );
110            }
111        }
112        $this->logFile = $logFile;
113        $this->openLogFile();
114
115        return $this;
116    }
117
118    /**
119     * Gets the path to the log file.
120     */
121    public function getLogFile(): string
122    {
123        return $this->logFile;
124    }
125
126    /**
127     * Writes the log record
128     *
129     * @param LogRecord $record Log record
130     * @return WriterInterface $this
131     * @throws \RuntimeException
132     */
133    public function writeLog(LogRecord $record)
134    {
135        $data = '';
136        $context = $record->getData();
137        $message = $record->getMessage();
138        if (!empty($context)) {
139            // Fold an exception into the message, and string-ify it into context so it can be jsonified.
140            if (isset($context['exception']) && $context['exception'] instanceof \Throwable) {
141                $message .= $this->formatException($context['exception']);
142                $context['exception'] = (string)$context['exception'];
143            }
144            $data = '- ' . json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
145        }
146
147        $message = sprintf(
148            '%s [%s] request="%s" component="%s": %s %s',
149            date('r', (int)$record->getCreated()),
150            strtoupper($record->getLevel()),
151            $record->getRequestId(),
152            $record->getComponent(),
153            $this->interpolate($message, $context),
154            $data
155        );
156
157        if (false === fwrite(self::$logFileHandles[$this->logFile], $message . LF)) {
158            throw new \RuntimeException('Could not write log record to log file', 1345036335);
159        }
160
161        return $this;
162    }
163
164    /**
165     * Opens the log file handle
166     *
167     * @throws \RuntimeException if the log file can't be opened.
168     */
169    protected function openLogFile()
170    {
171        if (isset(self::$logFileHandlesCount[$this->logFile])) {
172            self::$logFileHandlesCount[$this->logFile]++;
173        } else {
174            self::$logFileHandlesCount[$this->logFile] = 1;
175        }
176        if (isset(self::$logFileHandles[$this->logFile]) && is_resource(self::$logFileHandles[$this->logFile] ?? false)) {
177            return;
178        }
179
180        $this->createLogFile();
181        self::$logFileHandles[$this->logFile] = fopen($this->logFile, 'a');
182        if (!is_resource(self::$logFileHandles[$this->logFile])) {
183            throw new \RuntimeException('Could not open log file "' . $this->logFile . '"', 1321804422);
184        }
185    }
186
187    /**
188     * Closes the log file handle.
189     */
190    protected function closeLogFile()
191    {
192        if (!empty(self::$logFileHandles[$this->logFile]) && is_resource(self::$logFileHandles[$this->logFile])) {
193            fclose(self::$logFileHandles[$this->logFile]);
194            unset(self::$logFileHandles[$this->logFile]);
195        }
196    }
197
198    /**
199     * Creates the log file with correct permissions
200     * and parent directories, if needed
201     */
202    protected function createLogFile()
203    {
204        if (file_exists($this->logFile)) {
205            return;
206        }
207
208        // skip mkdir if logFile refers to any scheme but vfs://, file:// or empty
209        $scheme = parse_url($this->logFile, PHP_URL_SCHEME);
210        if ($scheme === null || $scheme === 'file' || $scheme === 'vfs' || PathUtility::isAbsolutePath($this->logFile)) {
211            // remove file:/ before creating the directory
212            $logFileDirectory = PathUtility::dirname((string)preg_replace('#^file:/#', '', $this->logFile));
213            if (!@is_dir($logFileDirectory)) {
214                GeneralUtility::mkdir_deep($logFileDirectory);
215                // create .htaccess file if log file is within the site path
216                if (PathUtility::getCommonPrefix([Environment::getPublicPath() . '/', $logFileDirectory]) === (Environment::getPublicPath() . '/')) {
217                    // only create .htaccess, if we created the directory on our own
218                    $this->createHtaccessFile($logFileDirectory . '/.htaccess');
219                }
220            }
221        }
222        // create the log file
223        GeneralUtility::writeFile($this->logFile, '');
224    }
225
226    /**
227     * Creates .htaccess file inside a new directory to access protect it
228     *
229     * @param string $htaccessFile Path of .htaccess file
230     */
231    protected function createHtaccessFile($htaccessFile)
232    {
233        // write .htaccess file to protect the log file
234        if (!empty($GLOBALS['TYPO3_CONF_VARS']['SYS']['generateApacheHtaccess']) && !file_exists($htaccessFile)) {
235            $htaccessContent = <<<END
236# Apache < 2.3
237<IfModule !mod_authz_core.c>
238	Order allow,deny
239	Deny from all
240	Satisfy All
241</IfModule>
242
243# Apache ≥ 2.3
244<IfModule mod_authz_core.c>
245	Require all denied
246</IfModule>
247END;
248            GeneralUtility::writeFile($htaccessFile, $htaccessContent);
249        }
250    }
251
252    /**
253     * Returns the path to the default log file.
254     * Uses the defaultLogFileTemplate and replaces the %s placeholder with a short MD5 hash
255     * based on a static string and the current encryption key.
256     *
257     * @return string
258     */
259    protected function getDefaultLogFileName()
260    {
261        $namePart = substr(GeneralUtility::hmac($this->defaultLogFileTemplate, 'defaultLogFile'), 0, 10);
262        if ($this->logFileInfix !== '') {
263            $namePart = $this->logFileInfix . '_' . $namePart;
264        }
265        return Environment::getVarPath() . sprintf($this->defaultLogFileTemplate, $namePart);
266    }
267}
268