1<?php
2/**
3 * CakePHP(tm) :  Rapid Development Framework (https://cakephp.org)
4 * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
5 *
6 * Licensed under The MIT License
7 * For full copyright and license information, please see the LICENSE.txt
8 * Redistributions of files must retain the above copyright notice.
9 *
10 * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
11 * @link          https://cakefoundation.org CakePHP(tm) Project
12 * @since         1.3.0
13 * @license       https://opensource.org/licenses/mit-license.php MIT License
14 */
15namespace Cake\Log\Engine;
16
17use Cake\Core\Configure;
18use Cake\Utility\Text;
19
20/**
21 * File Storage stream for Logging. Writes logs to different files
22 * based on the level of log it is.
23 */
24class FileLog extends BaseLog
25{
26    /**
27     * Default config for this class
28     *
29     * - `levels` string or array, levels the engine is interested in
30     * - `scopes` string or array, scopes the engine is interested in
31     * - `file` Log file name
32     * - `path` The path to save logs on.
33     * - `size` Used to implement basic log file rotation. If log file size
34     *   reaches specified size the existing file is renamed by appending timestamp
35     *   to filename and new log file is created. Can be integer bytes value or
36     *   human readable string values like '10MB', '100KB' etc.
37     * - `rotate` Log files are rotated specified times before being removed.
38     *   If value is 0, old versions are removed rather then rotated.
39     * - `mask` A mask is applied when log files are created. Left empty no chmod
40     *   is made.
41     *
42     * @var array
43     */
44    protected $_defaultConfig = [
45        'path' => null,
46        'file' => null,
47        'types' => null,
48        'levels' => [],
49        'scopes' => [],
50        'rotate' => 10,
51        'size' => 10485760, // 10MB
52        'mask' => null,
53    ];
54
55    /**
56     * Path to save log files on.
57     *
58     * @var string|null
59     */
60    protected $_path;
61
62    /**
63     * The name of the file to save logs into.
64     *
65     * @var string|null
66     */
67    protected $_file;
68
69    /**
70     * Max file size, used for log file rotation.
71     *
72     * @var int|null
73     */
74    protected $_size;
75
76    /**
77     * Sets protected properties based on config provided
78     *
79     * @param array $config Configuration array
80     */
81    public function __construct(array $config = [])
82    {
83        parent::__construct($config);
84
85        if (!empty($this->_config['path'])) {
86            $this->_path = $this->_config['path'];
87        }
88        if (
89            $this->_path !== null &&
90            Configure::read('debug') &&
91            !is_dir($this->_path)
92        ) {
93            mkdir($this->_path, 0775, true);
94        }
95
96        if (!empty($this->_config['file'])) {
97            $this->_file = $this->_config['file'];
98            if (substr($this->_file, -4) !== '.log') {
99                $this->_file .= '.log';
100            }
101        }
102
103        if (!empty($this->_config['size'])) {
104            if (is_numeric($this->_config['size'])) {
105                $this->_size = (int)$this->_config['size'];
106            } else {
107                $this->_size = Text::parseFileSize($this->_config['size']);
108            }
109        }
110    }
111
112    /**
113     * Implements writing to log files.
114     *
115     * @param string $level The severity level of the message being written.
116     *    See Cake\Log\Log::$_levels for list of possible levels.
117     * @param string $message The message you want to log.
118     * @param array $context Additional information about the logged message
119     * @return bool success of write.
120     */
121    public function log($level, $message, array $context = [])
122    {
123        $message = $this->_format($message, $context);
124        $output = date('Y-m-d H:i:s') . ' ' . ucfirst($level) . ': ' . $message . "\n";
125        $filename = $this->_getFilename($level);
126        if ($this->_size) {
127            $this->_rotateFile($filename);
128        }
129
130        $pathname = $this->_path . $filename;
131        $mask = $this->_config['mask'];
132        if (!$mask) {
133            return file_put_contents($pathname, $output, FILE_APPEND);
134        }
135
136        $exists = file_exists($pathname);
137        $result = file_put_contents($pathname, $output, FILE_APPEND);
138        static $selfError = false;
139
140        if (!$selfError && !$exists && !chmod($pathname, (int)$mask)) {
141            $selfError = true;
142            trigger_error(vsprintf(
143                'Could not apply permission mask "%s" on log file "%s"',
144                [$mask, $pathname]
145            ), E_USER_WARNING);
146            $selfError = false;
147        }
148
149        return $result;
150    }
151
152    /**
153     * Get filename
154     *
155     * @param string $level The level of log.
156     * @return string File name
157     */
158    protected function _getFilename($level)
159    {
160        $debugTypes = ['notice', 'info', 'debug'];
161
162        if ($this->_file) {
163            $filename = $this->_file;
164        } elseif ($level === 'error' || $level === 'warning') {
165            $filename = 'error.log';
166        } elseif (in_array($level, $debugTypes)) {
167            $filename = 'debug.log';
168        } else {
169            $filename = $level . '.log';
170        }
171
172        return $filename;
173    }
174
175    /**
176     * Rotate log file if size specified in config is reached.
177     * Also if `rotate` count is reached oldest file is removed.
178     *
179     * @param string $filename Log file name
180     * @return bool|null True if rotated successfully or false in case of error.
181     *   Null if file doesn't need to be rotated.
182     */
183    protected function _rotateFile($filename)
184    {
185        $filePath = $this->_path . $filename;
186        clearstatcache(true, $filePath);
187
188        if (
189            !file_exists($filePath) ||
190            filesize($filePath) < $this->_size
191        ) {
192            return null;
193        }
194
195        $rotate = $this->_config['rotate'];
196        if ($rotate === 0) {
197            $result = unlink($filePath);
198        } else {
199            $result = rename($filePath, $filePath . '.' . time());
200        }
201
202        $files = glob($filePath . '.*');
203        if ($files) {
204            $filesToDelete = count($files) - $rotate;
205            while ($filesToDelete > 0) {
206                unlink(array_shift($files));
207                $filesToDelete--;
208            }
209        }
210
211        return $result;
212    }
213}
214