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 InvalidArgumentException;
15use Monolog\Logger;
16use Monolog\Utils;
17
18/**
19 * Stores logs to files that are rotated every day and a limited number of files are kept.
20 *
21 * This rotation is only intended to be used as a workaround. Using logrotate to
22 * handle the rotation is strongly encouraged when you can use it.
23 *
24 * @author Christophe Coevoet <stof@notk.org>
25 * @author Jordi Boggiano <j.boggiano@seld.be>
26 */
27class RotatingFileHandler extends StreamHandler
28{
29    public const FILE_PER_DAY = 'Y-m-d';
30    public const FILE_PER_MONTH = 'Y-m';
31    public const FILE_PER_YEAR = 'Y';
32
33    /** @var string */
34    protected $filename;
35    /** @var int */
36    protected $maxFiles;
37    /** @var bool */
38    protected $mustRotate;
39    /** @var \DateTimeImmutable */
40    protected $nextRotation;
41    /** @var string */
42    protected $filenameFormat;
43    /** @var string */
44    protected $dateFormat;
45
46    /**
47     * @param string     $filename
48     * @param int        $maxFiles       The maximal amount of files to keep (0 means unlimited)
49     * @param int|null   $filePermission Optional file permissions (default (0644) are only for owner read/write)
50     * @param bool       $useLocking     Try to lock log file before doing any writes
51     */
52    public function __construct(string $filename, int $maxFiles = 0, $level = Logger::DEBUG, bool $bubble = true, ?int $filePermission = null, bool $useLocking = false)
53    {
54        $this->filename = Utils::canonicalizePath($filename);
55        $this->maxFiles = $maxFiles;
56        $this->nextRotation = new \DateTimeImmutable('tomorrow');
57        $this->filenameFormat = '{filename}-{date}';
58        $this->dateFormat = static::FILE_PER_DAY;
59
60        parent::__construct($this->getTimedFilename(), $level, $bubble, $filePermission, $useLocking);
61    }
62
63    /**
64     * {@inheritDoc}
65     */
66    public function close(): void
67    {
68        parent::close();
69
70        if (true === $this->mustRotate) {
71            $this->rotate();
72        }
73    }
74
75    /**
76     * {@inheritDoc}
77     */
78    public function reset()
79    {
80        parent::reset();
81
82        if (true === $this->mustRotate) {
83            $this->rotate();
84        }
85    }
86
87    public function setFilenameFormat(string $filenameFormat, string $dateFormat): self
88    {
89        if (!preg_match('{^[Yy](([/_.-]?m)([/_.-]?d)?)?$}', $dateFormat)) {
90            throw new InvalidArgumentException(
91                'Invalid date format - format must be one of '.
92                'RotatingFileHandler::FILE_PER_DAY ("Y-m-d"), RotatingFileHandler::FILE_PER_MONTH ("Y-m") '.
93                'or RotatingFileHandler::FILE_PER_YEAR ("Y"), or you can set one of the '.
94                'date formats using slashes, underscores and/or dots instead of dashes.'
95            );
96        }
97        if (substr_count($filenameFormat, '{date}') === 0) {
98            throw new InvalidArgumentException(
99                'Invalid filename format - format must contain at least `{date}`, because otherwise rotating is impossible.'
100            );
101        }
102        $this->filenameFormat = $filenameFormat;
103        $this->dateFormat = $dateFormat;
104        $this->url = $this->getTimedFilename();
105        $this->close();
106
107        return $this;
108    }
109
110    /**
111     * {@inheritDoc}
112     */
113    protected function write(array $record): void
114    {
115        // on the first record written, if the log is new, we should rotate (once per day)
116        if (null === $this->mustRotate) {
117            $this->mustRotate = null === $this->url || !file_exists($this->url);
118        }
119
120        if ($this->nextRotation <= $record['datetime']) {
121            $this->mustRotate = true;
122            $this->close();
123        }
124
125        parent::write($record);
126    }
127
128    /**
129     * Rotates the files.
130     */
131    protected function rotate(): void
132    {
133        // update filename
134        $this->url = $this->getTimedFilename();
135        $this->nextRotation = new \DateTimeImmutable('tomorrow');
136
137        // skip GC of old logs if files are unlimited
138        if (0 === $this->maxFiles) {
139            return;
140        }
141
142        $logFiles = glob($this->getGlobPattern());
143        if (false === $logFiles) {
144            // failed to glob
145            return;
146        }
147
148        if ($this->maxFiles >= count($logFiles)) {
149            // no files to remove
150            return;
151        }
152
153        // Sorting the files by name to remove the older ones
154        usort($logFiles, function ($a, $b) {
155            return strcmp($b, $a);
156        });
157
158        foreach (array_slice($logFiles, $this->maxFiles) as $file) {
159            if (is_writable($file)) {
160                // suppress errors here as unlink() might fail if two processes
161                // are cleaning up/rotating at the same time
162                set_error_handler(function (int $errno, string $errstr, string $errfile, int $errline): bool {
163                    return false;
164                });
165                unlink($file);
166                restore_error_handler();
167            }
168        }
169
170        $this->mustRotate = false;
171    }
172
173    protected function getTimedFilename(): string
174    {
175        $fileInfo = pathinfo($this->filename);
176        $timedFilename = str_replace(
177            ['{filename}', '{date}'],
178            [$fileInfo['filename'], date($this->dateFormat)],
179            $fileInfo['dirname'] . '/' . $this->filenameFormat
180        );
181
182        if (isset($fileInfo['extension'])) {
183            $timedFilename .= '.'.$fileInfo['extension'];
184        }
185
186        return $timedFilename;
187    }
188
189    protected function getGlobPattern(): string
190    {
191        $fileInfo = pathinfo($this->filename);
192        $glob = str_replace(
193            ['{filename}', '{date}'],
194            [$fileInfo['filename'], '[0-9][0-9][0-9][0-9]*'],
195            $fileInfo['dirname'] . '/' . $this->filenameFormat
196        );
197        if (isset($fileInfo['extension'])) {
198            $glob .= '.'.$fileInfo['extension'];
199        }
200
201        return $glob;
202    }
203}
204