1 | <?php |
||||
2 | |||||
3 | declare(strict_types=1); |
||||
4 | |||||
5 | namespace Yiisoft\Log\Target\File; |
||||
6 | |||||
7 | use InvalidArgumentException; |
||||
8 | use RuntimeException; |
||||
9 | use Yiisoft\Files\FileHelper; |
||||
10 | |||||
11 | use function chmod; |
||||
12 | use function copy; |
||||
13 | use function extension_loaded; |
||||
14 | use function fclose; |
||||
15 | use function feof; |
||||
16 | use function file_exists; |
||||
17 | use function filesize; |
||||
18 | use function flock; |
||||
19 | use function fread; |
||||
20 | use function ftruncate; |
||||
21 | use function is_file; |
||||
22 | use function rename; |
||||
23 | use function sprintf; |
||||
24 | use function substr; |
||||
25 | use function unlink; |
||||
26 | |||||
27 | use const DIRECTORY_SEPARATOR; |
||||
28 | use const LOCK_EX; |
||||
29 | use const LOCK_SH; |
||||
30 | use const LOCK_UN; |
||||
31 | |||||
32 | /** |
||||
33 | * FileRotator takes care of rotating files. |
||||
34 | * |
||||
35 | * If the size of the file exceeds {@see FileRotator::$maxFileSize} (in kilo-bytes), |
||||
36 | * a rotation will be performed, which renames the current file by suffixing the file name with '.1'. |
||||
37 | * |
||||
38 | * All existing files are moved backwards by one place, i.e., '.2' to '.3', '.1' to '.2', and so on. If compression |
||||
39 | * is enabled {@see FileRotator::$compressRotatedFiles}, the rotated files will be compressed into the '.gz' format. |
||||
40 | * The property {@see FileRotator::$maxFiles} specifies how many history files to keep. |
||||
41 | */ |
||||
42 | final class FileRotator implements FileRotatorInterface |
||||
43 | { |
||||
44 | /** |
||||
45 | * The extension of the compressed rotated files. |
||||
46 | */ |
||||
47 | private const COMPRESS_EXTENSION = '.gz'; |
||||
48 | |||||
49 | /** |
||||
50 | * @var int The maximum file size, in kilo-bytes. Defaults to 10240, meaning 10MB. |
||||
51 | */ |
||||
52 | private int $maxFileSize; |
||||
53 | |||||
54 | /** |
||||
55 | * @var int The number of files used for rotation. Defaults to 5. |
||||
56 | */ |
||||
57 | private int $maxFiles; |
||||
58 | |||||
59 | /** |
||||
60 | * @var int|null The permission to be set for newly created files. |
||||
61 | * This value will be used by PHP chmod() function. No umask will be applied. |
||||
62 | * If not set, the permission will be determined by the current environment. |
||||
63 | */ |
||||
64 | private ?int $fileMode; |
||||
65 | |||||
66 | /** |
||||
67 | * @var bool|null Whether to rotate files by copy and truncate in contrast to rotation by |
||||
68 | * renaming files. Defaults to `true` to be more compatible with log tailers and is windows |
||||
69 | * systems which do not play well with rename on open files. Rotation by renaming however is |
||||
70 | * a bit faster. |
||||
71 | * |
||||
72 | * The problem with windows systems where the [rename()](http://www.php.net/manual/en/function.rename.php) |
||||
73 | * function does not work with files that are opened by some process is described in a |
||||
74 | * [comment by Martin Pelletier](http://www.php.net/manual/en/function.rename.php#102274) in |
||||
75 | * the PHP documentation. By setting rotateByCopy to `true` you can work |
||||
76 | * around this problem. |
||||
77 | */ |
||||
78 | private ?bool $rotateByCopy; |
||||
79 | |||||
80 | /** |
||||
81 | * @var bool Whether or not to compress rotated files with gzip. Defaults to `false`. |
||||
82 | * |
||||
83 | * If compression is enabled, the rotated files will be compressed into the '.gz' format. |
||||
84 | */ |
||||
85 | private bool $compressRotatedFiles; |
||||
86 | |||||
87 | /** |
||||
88 | * @param int $maxFileSize The maximum file size, in kilo-bytes. Defaults to 10240, meaning 10MB. |
||||
89 | * @param int $maxFiles The number of files used for rotation. Defaults to 5. |
||||
90 | * @param int|null $fileMode The permission to be set for newly created files. |
||||
91 | * @param bool|null $rotateByCopy Whether to rotate files by copying and truncating or renaming them. |
||||
92 | * @param bool $compressRotatedFiles Whether or not to compress rotated files with gzip. |
||||
93 | */ |
||||
94 | 15 | public function __construct( |
|||
95 | int $maxFileSize = 10240, |
||||
96 | int $maxFiles = 5, |
||||
97 | int $fileMode = null, |
||||
98 | bool $rotateByCopy = null, |
||||
99 | bool $compressRotatedFiles = false |
||||
100 | ) { |
||||
101 | 15 | $this->checkCannotBeLowerThanOne($maxFileSize, '$maxFileSize'); |
|||
102 | 14 | $this->checkCannotBeLowerThanOne($maxFiles, '$maxFiles'); |
|||
103 | |||||
104 | 13 | $this->maxFileSize = $maxFileSize; |
|||
105 | 13 | $this->maxFiles = $maxFiles; |
|||
106 | 13 | $this->fileMode = $fileMode; |
|||
107 | 13 | $this->rotateByCopy = $rotateByCopy ?? $this->isRunningOnWindows(); |
|||
108 | |||||
109 | 13 | if ($compressRotatedFiles && !extension_loaded('zlib')) { |
|||
110 | throw new RuntimeException(sprintf( |
||||
111 | 'The %s requires the PHP extension "ext-zlib" to compress rotated files.', |
||||
112 | self::class, |
||||
113 | )); |
||||
114 | } |
||||
115 | |||||
116 | 13 | $this->compressRotatedFiles = $compressRotatedFiles; |
|||
117 | 13 | } |
|||
118 | |||||
119 | 8 | public function rotateFile(string $file): void |
|||
120 | { |
||||
121 | 8 | for ($i = $this->maxFiles; $i >= 0; --$i) { |
|||
122 | // `$i === 0` is the original file |
||||
123 | 8 | $rotateFile = $file . ($i === 0 ? '' : '.' . $i); |
|||
124 | 8 | $newFile = $file . '.' . ($i + 1); |
|||
125 | |||||
126 | 8 | if ($i === $this->maxFiles) { |
|||
127 | 8 | $this->safeRemove($this->compressRotatedFiles ? $rotateFile . self::COMPRESS_EXTENSION : $rotateFile); |
|||
128 | 8 | continue; |
|||
129 | } |
||||
130 | |||||
131 | 8 | if ($this->compressRotatedFiles && is_file($rotateFile . self::COMPRESS_EXTENSION)) { |
|||
132 | 2 | $this->rotate($rotateFile . self::COMPRESS_EXTENSION, $newFile . self::COMPRESS_EXTENSION); |
|||
133 | 2 | continue; |
|||
134 | } |
||||
135 | |||||
136 | 8 | if (!is_file($rotateFile)) { |
|||
137 | 8 | continue; |
|||
138 | } |
||||
139 | |||||
140 | 8 | $this->rotate($rotateFile, $newFile); |
|||
141 | 8 | $this->compress($newFile); |
|||
142 | |||||
143 | 8 | if ($i === 0) { |
|||
144 | 8 | $this->clear($rotateFile); |
|||
145 | } |
||||
146 | } |
||||
147 | 8 | } |
|||
148 | |||||
149 | 13 | public function shouldRotateFile(string $file): bool |
|||
150 | { |
||||
151 | 13 | return file_exists($file) && @filesize($file) > ($this->maxFileSize * 1024); |
|||
152 | } |
||||
153 | |||||
154 | /*** |
||||
155 | * Copies or renames rotated file into new file. |
||||
156 | * |
||||
157 | * @param string $rotateFile |
||||
158 | * @param string $newFile |
||||
159 | */ |
||||
160 | 8 | private function rotate(string $rotateFile, string $newFile): void |
|||
161 | { |
||||
162 | 8 | if ($this->rotateByCopy !== true) { |
|||
163 | 4 | $this->safeRemove($newFile); |
|||
164 | 4 | rename($rotateFile, $newFile); |
|||
165 | 4 | return; |
|||
166 | } |
||||
167 | |||||
168 | 4 | copy($rotateFile, $newFile); |
|||
169 | |||||
170 | 4 | if ($this->fileMode !== null && (!$this->compressRotatedFiles || $this->isCompressed($newFile))) { |
|||
171 | 1 | chmod($newFile, $this->fileMode); |
|||
172 | } |
||||
173 | 4 | } |
|||
174 | |||||
175 | /** |
||||
176 | * Compresses a file with gzip and renames it by appending `.gz` to the file. |
||||
177 | * |
||||
178 | * @param string $file |
||||
179 | */ |
||||
180 | 8 | private function compress(string $file): void |
|||
181 | { |
||||
182 | 8 | if (!$this->compressRotatedFiles || $this->isCompressed($file)) { |
|||
183 | 4 | return; |
|||
184 | } |
||||
185 | |||||
186 | 4 | $filePointer = FileHelper::openFile($file, 'rb'); |
|||
187 | 4 | flock($filePointer, LOCK_SH); |
|||
188 | 4 | $gzFile = $file . self::COMPRESS_EXTENSION; |
|||
189 | 4 | $gzFilePointer = gzopen($gzFile, 'wb9'); |
|||
190 | |||||
191 | 4 | while (!feof($filePointer)) { |
|||
192 | 4 | gzwrite($gzFilePointer, fread($filePointer, 8192)); |
|||
193 | } |
||||
194 | |||||
195 | 4 | flock($filePointer, LOCK_UN); |
|||
196 | 4 | fclose($filePointer); |
|||
197 | 4 | gzclose($gzFilePointer); |
|||
198 | 4 | @unlink($file); |
|||
0 ignored issues
–
show
|
|||||
199 | |||||
200 | 4 | if ($this->fileMode !== null) { |
|||
201 | 2 | chmod($gzFile, $this->fileMode); |
|||
202 | } |
||||
203 | 4 | } |
|||
204 | |||||
205 | /*** |
||||
206 | * Clears the file without closing any other process open handles. |
||||
207 | * |
||||
208 | * @param string $file |
||||
209 | */ |
||||
210 | 8 | private function clear(string $file): void |
|||
211 | { |
||||
212 | 8 | $filePointer = FileHelper::openFile($file, 'ab'); |
|||
213 | |||||
214 | 8 | flock($filePointer, LOCK_EX); |
|||
215 | 8 | ftruncate($filePointer, 0); |
|||
216 | 8 | flock($filePointer, LOCK_UN); |
|||
217 | 8 | fclose($filePointer); |
|||
218 | 8 | } |
|||
219 | |||||
220 | /** |
||||
221 | * Checks the existence of file and removes it. |
||||
222 | * |
||||
223 | * @param string $file |
||||
224 | */ |
||||
225 | 8 | private function safeRemove(string $file): void |
|||
226 | { |
||||
227 | 8 | if (is_file($file)) { |
|||
228 | 4 | @unlink($file); |
|||
0 ignored issues
–
show
It seems like you do not handle an error condition for
unlink() . This can introduce security issues, and is generally not recommended.
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
If you suppress an error, we recommend checking for the error condition explicitly: // For example instead of
@mkdir($dir);
// Better use
if (@mkdir($dir) === false) {
throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
|
|||||
229 | } |
||||
230 | 8 | } |
|||
231 | |||||
232 | /** |
||||
233 | * Whether the file is compressed. |
||||
234 | * |
||||
235 | * @param string $file |
||||
236 | * |
||||
237 | * @return bool |
||||
238 | */ |
||||
239 | 4 | private function isCompressed(string $file): bool |
|||
240 | { |
||||
241 | 4 | return substr($file, -3, 3) === self::COMPRESS_EXTENSION; |
|||
242 | } |
||||
243 | |||||
244 | /** |
||||
245 | * Whether it works on Windows OS. |
||||
246 | * |
||||
247 | * @return bool |
||||
248 | */ |
||||
249 | 1 | private function isRunningOnWindows(): bool |
|||
250 | { |
||||
251 | 1 | return DIRECTORY_SEPARATOR === '\\'; |
|||
252 | } |
||||
253 | |||||
254 | /** |
||||
255 | * Checks that the value cannot be lower than one. |
||||
256 | * |
||||
257 | * @param int $value The value to be checked. |
||||
258 | * @param string $argumentName The name of the argument to check. |
||||
259 | */ |
||||
260 | 15 | private function checkCannotBeLowerThanOne(int $value, string $argumentName): void |
|||
261 | { |
||||
262 | 15 | if ($value < 1) { |
|||
263 | 2 | throw new InvalidArgumentException(sprintf( |
|||
264 | 2 | 'The argument "%s" cannot be lower than 1.', |
|||
265 | 2 | $argumentName, |
|||
266 | )); |
||||
267 | } |
||||
268 | 14 | } |
|||
269 | } |
||||
270 |
If you suppress an error, we recommend checking for the error condition explicitly: