1 | <?php |
||
2 | /** |
||
3 | * @link http://www.yiiframework.com/ |
||
4 | * @copyright Copyright (c) 2008 Yii Software LLC |
||
5 | * @license http://www.yiiframework.com/license/ |
||
6 | */ |
||
7 | |||
8 | namespace yii\helpers; |
||
9 | |||
10 | use Yii; |
||
11 | use yii\base\ErrorException; |
||
12 | use yii\base\InvalidArgumentException; |
||
13 | use yii\base\InvalidConfigException; |
||
14 | |||
15 | /** |
||
16 | * BaseFileHelper provides concrete implementation for [[FileHelper]]. |
||
17 | * |
||
18 | * Do not use BaseFileHelper. Use [[FileHelper]] instead. |
||
19 | * |
||
20 | * @author Qiang Xue <[email protected]> |
||
21 | * @author Alex Makarov <[email protected]> |
||
22 | * @since 2.0 |
||
23 | */ |
||
24 | class BaseFileHelper |
||
25 | { |
||
26 | const PATTERN_NODIR = 1; |
||
27 | const PATTERN_ENDSWITH = 4; |
||
28 | const PATTERN_MUSTBEDIR = 8; |
||
29 | const PATTERN_NEGATIVE = 16; |
||
30 | const PATTERN_CASE_INSENSITIVE = 32; |
||
31 | |||
32 | /** |
||
33 | * @var string the path (or alias) of a PHP file containing MIME type information. |
||
34 | */ |
||
35 | public static $mimeMagicFile = '@yii/helpers/mimeTypes.php'; |
||
36 | /** |
||
37 | * @var string the path (or alias) of a PHP file containing MIME aliases. |
||
38 | * @since 2.0.14 |
||
39 | */ |
||
40 | public static $mimeAliasesFile = '@yii/helpers/mimeAliases.php'; |
||
41 | |||
42 | |||
43 | /** |
||
44 | * Normalizes a file/directory path. |
||
45 | * |
||
46 | * The normalization does the following work: |
||
47 | * |
||
48 | * - Convert all directory separators into `DIRECTORY_SEPARATOR` (e.g. "\a/b\c" becomes "/a/b/c") |
||
49 | * - Remove trailing directory separators (e.g. "/a/b/c/" becomes "/a/b/c") |
||
50 | * - Turn multiple consecutive slashes into a single one (e.g. "/a///b/c" becomes "/a/b/c") |
||
51 | * - Remove ".." and "." based on their meanings (e.g. "/a/./b/../c" becomes "/a/c") |
||
52 | * |
||
53 | * Note: For registered stream wrappers, the consecutive slashes rule |
||
54 | * and ".."/"." translations are skipped. |
||
55 | * |
||
56 | * @param string $path the file/directory path to be normalized |
||
57 | * @param string $ds the directory separator to be used in the normalized result. Defaults to `DIRECTORY_SEPARATOR`. |
||
58 | * @return string the normalized file/directory path |
||
59 | */ |
||
60 | 36 | public static function normalizePath($path, $ds = DIRECTORY_SEPARATOR) |
|
61 | { |
||
62 | 36 | $path = rtrim(strtr($path, '/\\', $ds . $ds), $ds); |
|
63 | 36 | if (strpos($ds . $path, "{$ds}.") === false && strpos($path, "{$ds}{$ds}") === false) { |
|
64 | 35 | return $path; |
|
65 | } |
||
66 | // fix #17235 stream wrappers |
||
67 | 1 | foreach (stream_get_wrappers() as $protocol) { |
|
68 | 1 | if (strpos($path, "{$protocol}://") === 0) { |
|
69 | 1 | return $path; |
|
70 | } |
||
71 | } |
||
72 | // the path may contain ".", ".." or double slashes, need to clean them up |
||
73 | 1 | if (strpos($path, "{$ds}{$ds}") === 0 && $ds == '\\') { |
|
74 | 1 | $parts = [$ds]; |
|
75 | } else { |
||
76 | 1 | $parts = []; |
|
77 | } |
||
78 | 1 | foreach (explode($ds, $path) as $part) { |
|
79 | 1 | if ($part === '..' && !empty($parts) && end($parts) !== '..') { |
|
80 | 1 | array_pop($parts); |
|
81 | 1 | } elseif ($part === '.' || $part === '' && !empty($parts)) { |
|
82 | 1 | continue; |
|
83 | } else { |
||
84 | 1 | $parts[] = $part; |
|
85 | } |
||
86 | } |
||
87 | 1 | $path = implode($ds, $parts); |
|
88 | 1 | return $path === '' ? '.' : $path; |
|
89 | } |
||
90 | |||
91 | /** |
||
92 | * Returns the localized version of a specified file. |
||
93 | * |
||
94 | * The searching is based on the specified language code. In particular, |
||
95 | * a file with the same name will be looked for under the subdirectory |
||
96 | * whose name is the same as the language code. For example, given the file "path/to/view.php" |
||
97 | * and language code "zh-CN", the localized file will be looked for as |
||
98 | * "path/to/zh-CN/view.php". If the file is not found, it will try a fallback with just a language code that is |
||
99 | * "zh" i.e. "path/to/zh/view.php". If it is not found as well the original file will be returned. |
||
100 | * |
||
101 | * If the target and the source language codes are the same, |
||
102 | * the original file will be returned. |
||
103 | * |
||
104 | * @param string $file the original file |
||
105 | * @param string $language the target language that the file should be localized to. |
||
106 | * If not set, the value of [[\yii\base\Application::language]] will be used. |
||
107 | * @param string $sourceLanguage the language that the original file is in. |
||
108 | * If not set, the value of [[\yii\base\Application::sourceLanguage]] will be used. |
||
109 | * @return string the matching localized file, or the original file if the localized version is not found. |
||
110 | * If the target and the source language codes are the same, the original file will be returned. |
||
111 | */ |
||
112 | 77 | public static function localize($file, $language = null, $sourceLanguage = null) |
|
113 | { |
||
114 | 77 | if ($language === null) { |
|
115 | 76 | $language = Yii::$app->language; |
|
116 | } |
||
117 | 77 | if ($sourceLanguage === null) { |
|
118 | 76 | $sourceLanguage = Yii::$app->sourceLanguage; |
|
119 | } |
||
120 | 77 | if ($language === $sourceLanguage) { |
|
121 | 77 | return $file; |
|
122 | } |
||
123 | 1 | $desiredFile = dirname($file) . DIRECTORY_SEPARATOR . $language . DIRECTORY_SEPARATOR . basename($file); |
|
124 | 1 | if (is_file($desiredFile)) { |
|
125 | 1 | return $desiredFile; |
|
126 | } |
||
127 | |||
128 | $language = substr($language, 0, 2); |
||
129 | if ($language === $sourceLanguage) { |
||
130 | return $file; |
||
131 | } |
||
132 | $desiredFile = dirname($file) . DIRECTORY_SEPARATOR . $language . DIRECTORY_SEPARATOR . basename($file); |
||
133 | |||
134 | return is_file($desiredFile) ? $desiredFile : $file; |
||
135 | } |
||
136 | |||
137 | /** |
||
138 | * Determines the MIME type of the specified file. |
||
139 | * This method will first try to determine the MIME type based on |
||
140 | * [finfo_open](https://secure.php.net/manual/en/function.finfo-open.php). If the `fileinfo` extension is not installed, |
||
141 | * it will fall back to [[getMimeTypeByExtension()]] when `$checkExtension` is true. |
||
142 | * @param string $file the file name. |
||
143 | * @param string $magicFile name of the optional magic database file (or alias), usually something like `/path/to/magic.mime`. |
||
144 | * This will be passed as the second parameter to [finfo_open()](https://secure.php.net/manual/en/function.finfo-open.php) |
||
145 | * when the `fileinfo` extension is installed. If the MIME type is being determined based via [[getMimeTypeByExtension()]] |
||
146 | * and this is null, it will use the file specified by [[mimeMagicFile]]. |
||
147 | * @param bool $checkExtension whether to use the file extension to determine the MIME type in case |
||
148 | * `finfo_open()` cannot determine it. |
||
149 | * @return string the MIME type (e.g. `text/plain`). Null is returned if the MIME type cannot be determined. |
||
150 | * @throws InvalidConfigException when the `fileinfo` PHP extension is not installed and `$checkExtension` is `false`. |
||
151 | */ |
||
152 | 25 | public static function getMimeType($file, $magicFile = null, $checkExtension = true) |
|
153 | { |
||
154 | 25 | if ($magicFile !== null) { |
|
155 | $magicFile = Yii::getAlias($magicFile); |
||
156 | } |
||
157 | 25 | if (!extension_loaded('fileinfo')) { |
|
158 | if ($checkExtension) { |
||
159 | return static::getMimeTypeByExtension($file, $magicFile); |
||
160 | } |
||
161 | |||
162 | throw new InvalidConfigException('The fileinfo PHP extension is not installed.'); |
||
163 | } |
||
164 | 25 | $info = finfo_open(FILEINFO_MIME_TYPE, $magicFile); |
|
165 | |||
166 | 25 | if ($info) { |
|
0 ignored issues
–
show
introduced
by
Loading history...
|
|||
167 | 25 | $result = finfo_file($info, $file); |
|
168 | 25 | finfo_close($info); |
|
169 | |||
170 | 25 | if ($result !== false) { |
|
171 | 25 | return $result; |
|
172 | } |
||
173 | } |
||
174 | |||
175 | return $checkExtension ? static::getMimeTypeByExtension($file, $magicFile) : null; |
||
176 | } |
||
177 | |||
178 | /** |
||
179 | * Determines the MIME type based on the extension name of the specified file. |
||
180 | * This method will use a local map between extension names and MIME types. |
||
181 | * @param string $file the file name. |
||
182 | * @param string $magicFile the path (or alias) of the file that contains all available MIME type information. |
||
183 | * If this is not set, the file specified by [[mimeMagicFile]] will be used. |
||
184 | * @return string|null the MIME type. Null is returned if the MIME type cannot be determined. |
||
185 | */ |
||
186 | 10 | public static function getMimeTypeByExtension($file, $magicFile = null) |
|
187 | { |
||
188 | 10 | $mimeTypes = static::loadMimeTypes($magicFile); |
|
189 | |||
190 | 10 | if (($ext = pathinfo($file, PATHINFO_EXTENSION)) !== '') { |
|
191 | 10 | $ext = strtolower($ext); |
|
192 | 10 | if (isset($mimeTypes[$ext])) { |
|
193 | 10 | return $mimeTypes[$ext]; |
|
194 | } |
||
195 | } |
||
196 | |||
197 | 1 | return null; |
|
198 | } |
||
199 | |||
200 | /** |
||
201 | * Determines the extensions by given MIME type. |
||
202 | * This method will use a local map between extension names and MIME types. |
||
203 | * @param string $mimeType file MIME type. |
||
204 | * @param string $magicFile the path (or alias) of the file that contains all available MIME type information. |
||
205 | * If this is not set, the file specified by [[mimeMagicFile]] will be used. |
||
206 | * @return array the extensions corresponding to the specified MIME type |
||
207 | */ |
||
208 | 12 | public static function getExtensionsByMimeType($mimeType, $magicFile = null) |
|
209 | { |
||
210 | 12 | $aliases = static::loadMimeAliases(static::$mimeAliasesFile); |
|
211 | 12 | if (isset($aliases[$mimeType])) { |
|
212 | $mimeType = $aliases[$mimeType]; |
||
213 | } |
||
214 | |||
215 | 12 | $mimeTypes = static::loadMimeTypes($magicFile); |
|
216 | 12 | return array_keys($mimeTypes, mb_strtolower($mimeType, 'UTF-8'), true); |
|
217 | } |
||
218 | |||
219 | private static $_mimeTypes = []; |
||
220 | |||
221 | /** |
||
222 | * Loads MIME types from the specified file. |
||
223 | * @param string $magicFile the path (or alias) of the file that contains all available MIME type information. |
||
224 | * If this is not set, the file specified by [[mimeMagicFile]] will be used. |
||
225 | * @return array the mapping from file extensions to MIME types |
||
226 | */ |
||
227 | 22 | protected static function loadMimeTypes($magicFile) |
|
228 | { |
||
229 | 22 | if ($magicFile === null) { |
|
230 | 22 | $magicFile = static::$mimeMagicFile; |
|
231 | } |
||
232 | 22 | $magicFile = Yii::getAlias($magicFile); |
|
233 | 22 | if (!isset(self::$_mimeTypes[$magicFile])) { |
|
234 | 1 | self::$_mimeTypes[$magicFile] = require $magicFile; |
|
235 | } |
||
236 | |||
237 | 22 | return self::$_mimeTypes[$magicFile]; |
|
238 | } |
||
239 | |||
240 | private static $_mimeAliases = []; |
||
241 | |||
242 | /** |
||
243 | * Loads MIME aliases from the specified file. |
||
244 | * @param string $aliasesFile the path (or alias) of the file that contains MIME type aliases. |
||
245 | * If this is not set, the file specified by [[mimeAliasesFile]] will be used. |
||
246 | * @return array the mapping from file extensions to MIME types |
||
247 | * @since 2.0.14 |
||
248 | */ |
||
249 | 12 | protected static function loadMimeAliases($aliasesFile) |
|
250 | { |
||
251 | 12 | if ($aliasesFile === null) { |
|
252 | $aliasesFile = static::$mimeAliasesFile; |
||
253 | } |
||
254 | 12 | $aliasesFile = Yii::getAlias($aliasesFile); |
|
255 | 12 | if (!isset(self::$_mimeAliases[$aliasesFile])) { |
|
256 | 1 | self::$_mimeAliases[$aliasesFile] = require $aliasesFile; |
|
257 | } |
||
258 | |||
259 | 12 | return self::$_mimeAliases[$aliasesFile]; |
|
260 | } |
||
261 | |||
262 | /** |
||
263 | * Copies a whole directory as another one. |
||
264 | * The files and sub-directories will also be copied over. |
||
265 | * @param string $src the source directory |
||
266 | * @param string $dst the destination directory |
||
267 | * @param array $options options for directory copy. Valid options are: |
||
268 | * |
||
269 | * - dirMode: integer, the permission to be set for newly copied directories. Defaults to 0775. |
||
270 | * - fileMode: integer, the permission to be set for newly copied files. Defaults to the current environment setting. |
||
271 | * - filter: callback, a PHP callback that is called for each directory or file. |
||
272 | * The signature of the callback should be: `function ($path)`, where `$path` refers the full path to be filtered. |
||
273 | * The callback can return one of the following values: |
||
274 | * |
||
275 | * * true: the directory or file will be copied (the "only" and "except" options will be ignored) |
||
276 | * * false: the directory or file will NOT be copied (the "only" and "except" options will be ignored) |
||
277 | * * null: the "only" and "except" options will determine whether the directory or file should be copied |
||
278 | * |
||
279 | * - only: array, list of patterns that the file paths should match if they want to be copied. |
||
280 | * A path matches a pattern if it contains the pattern string at its end. |
||
281 | * For example, '.php' matches all file paths ending with '.php'. |
||
282 | * Note, the '/' characters in a pattern matches both '/' and '\' in the paths. |
||
283 | * If a file path matches a pattern in both "only" and "except", it will NOT be copied. |
||
284 | * - except: array, list of patterns that the files or directories should match if they want to be excluded from being copied. |
||
285 | * A path matches a pattern if it contains the pattern string at its end. |
||
286 | * Patterns ending with '/' apply to directory paths only, and patterns not ending with '/' |
||
287 | * apply to file paths only. For example, '/a/b' matches all file paths ending with '/a/b'; |
||
288 | * and '.svn/' matches directory paths ending with '.svn'. Note, the '/' characters in a pattern matches |
||
289 | * both '/' and '\' in the paths. |
||
290 | * - caseSensitive: boolean, whether patterns specified at "only" or "except" should be case sensitive. Defaults to true. |
||
291 | * - recursive: boolean, whether the files under the subdirectories should also be copied. Defaults to true. |
||
292 | * - beforeCopy: callback, a PHP callback that is called before copying each sub-directory or file. |
||
293 | * If the callback returns false, the copy operation for the sub-directory or file will be cancelled. |
||
294 | * The signature of the callback should be: `function ($from, $to)`, where `$from` is the sub-directory or |
||
295 | * file to be copied from, while `$to` is the copy target. |
||
296 | * - afterCopy: callback, a PHP callback that is called after each sub-directory or file is successfully copied. |
||
297 | * The signature of the callback should be: `function ($from, $to)`, where `$from` is the sub-directory or |
||
298 | * file copied from, while `$to` is the copy target. |
||
299 | * - copyEmptyDirectories: boolean, whether to copy empty directories. Set this to false to avoid creating directories |
||
300 | * that do not contain files. This affects directories that do not contain files initially as well as directories that |
||
301 | * do not contain files at the target destination because files have been filtered via `only` or `except`. |
||
302 | * Defaults to true. This option is available since version 2.0.12. Before 2.0.12 empty directories are always copied. |
||
303 | * @throws InvalidArgumentException if unable to open directory |
||
304 | */ |
||
305 | 17 | public static function copyDirectory($src, $dst, $options = []) |
|
306 | { |
||
307 | 17 | $src = static::normalizePath($src); |
|
308 | 17 | $dst = static::normalizePath($dst); |
|
309 | |||
310 | 17 | if ($src === $dst || strpos($dst, $src . DIRECTORY_SEPARATOR) === 0) { |
|
311 | 2 | throw new InvalidArgumentException('Trying to copy a directory to itself or a subdirectory.'); |
|
312 | } |
||
313 | 15 | $dstExists = is_dir($dst); |
|
314 | 15 | if (!$dstExists && (!isset($options['copyEmptyDirectories']) || $options['copyEmptyDirectories'])) { |
|
315 | 6 | static::createDirectory($dst, isset($options['dirMode']) ? $options['dirMode'] : 0775, true); |
|
316 | 6 | $dstExists = true; |
|
317 | } |
||
318 | |||
319 | 15 | $handle = opendir($src); |
|
320 | 15 | if ($handle === false) { |
|
321 | throw new InvalidArgumentException("Unable to open directory: $src"); |
||
322 | } |
||
323 | 15 | if (!isset($options['basePath'])) { |
|
324 | // this should be done only once |
||
325 | 15 | $options['basePath'] = realpath($src); |
|
326 | 15 | $options = static::normalizeOptions($options); |
|
327 | } |
||
328 | 15 | while (($file = readdir($handle)) !== false) { |
|
329 | 15 | if ($file === '.' || $file === '..') { |
|
330 | 15 | continue; |
|
331 | } |
||
332 | 13 | $from = $src . DIRECTORY_SEPARATOR . $file; |
|
333 | 13 | $to = $dst . DIRECTORY_SEPARATOR . $file; |
|
334 | 13 | if (static::filterPath($from, $options)) { |
|
335 | 13 | if (isset($options['beforeCopy']) && !call_user_func($options['beforeCopy'], $from, $to)) { |
|
336 | 2 | continue; |
|
337 | } |
||
338 | 11 | if (is_file($from)) { |
|
339 | 11 | if (!$dstExists) { |
|
340 | // delay creation of destination directory until the first file is copied to avoid creating empty directories |
||
341 | 5 | static::createDirectory($dst, isset($options['dirMode']) ? $options['dirMode'] : 0775, true); |
|
342 | 5 | $dstExists = true; |
|
343 | } |
||
344 | 11 | copy($from, $to); |
|
345 | 11 | if (isset($options['fileMode'])) { |
|
346 | 11 | @chmod($to, $options['fileMode']); |
|
347 | } |
||
348 | } else { |
||
349 | // recursive copy, defaults to true |
||
350 | 8 | if (!isset($options['recursive']) || $options['recursive']) { |
|
351 | 7 | static::copyDirectory($from, $to, $options); |
|
352 | } |
||
353 | } |
||
354 | 11 | if (isset($options['afterCopy'])) { |
|
355 | call_user_func($options['afterCopy'], $from, $to); |
||
356 | } |
||
357 | } |
||
358 | } |
||
359 | 15 | closedir($handle); |
|
360 | 15 | } |
|
361 | |||
362 | /** |
||
363 | * Removes a directory (and all its content) recursively. |
||
364 | * |
||
365 | * @param string $dir the directory to be deleted recursively. |
||
366 | * @param array $options options for directory remove. Valid options are: |
||
367 | * |
||
368 | * - traverseSymlinks: boolean, whether symlinks to the directories should be traversed too. |
||
369 | * Defaults to `false`, meaning the content of the symlinked directory would not be deleted. |
||
370 | * Only symlink would be removed in that default case. |
||
371 | * |
||
372 | * @throws ErrorException in case of failure |
||
373 | */ |
||
374 | 147 | public static function removeDirectory($dir, $options = []) |
|
375 | { |
||
376 | 147 | if (!is_dir($dir)) { |
|
377 | 33 | return; |
|
378 | } |
||
379 | 145 | if (!empty($options['traverseSymlinks']) || !is_link($dir)) { |
|
380 | 145 | if (!($handle = opendir($dir))) { |
|
381 | return; |
||
382 | } |
||
383 | 145 | while (($file = readdir($handle)) !== false) { |
|
384 | 145 | if ($file === '.' || $file === '..') { |
|
385 | 145 | continue; |
|
386 | } |
||
387 | 123 | $path = $dir . DIRECTORY_SEPARATOR . $file; |
|
388 | 123 | if (is_dir($path)) { |
|
389 | 76 | static::removeDirectory($path, $options); |
|
390 | } else { |
||
391 | 101 | static::unlink($path); |
|
392 | } |
||
393 | } |
||
394 | 145 | closedir($handle); |
|
395 | } |
||
396 | 145 | if (is_link($dir)) { |
|
397 | 2 | static::unlink($dir); |
|
398 | } else { |
||
399 | 145 | rmdir($dir); |
|
400 | } |
||
401 | 145 | } |
|
402 | |||
403 | /** |
||
404 | * Removes a file or symlink in a cross-platform way |
||
405 | * |
||
406 | * @param string $path |
||
407 | * @return bool |
||
408 | * |
||
409 | * @since 2.0.14 |
||
410 | */ |
||
411 | 102 | public static function unlink($path) |
|
412 | { |
||
413 | 102 | $isWindows = DIRECTORY_SEPARATOR === '\\'; |
|
414 | |||
415 | 102 | if (!$isWindows) { |
|
416 | 102 | return unlink($path); |
|
417 | } |
||
418 | |||
419 | if (is_link($path) && is_dir($path)) { |
||
420 | return rmdir($path); |
||
421 | } |
||
422 | |||
423 | try { |
||
424 | return unlink($path); |
||
425 | } catch (ErrorException $e) { |
||
426 | // last resort measure for Windows |
||
427 | if (function_exists('exec') && file_exists($path)) { |
||
428 | exec('DEL /F/Q ' . escapeshellarg($path)); |
||
429 | |||
430 | return !file_exists($path); |
||
431 | } |
||
432 | |||
433 | return false; |
||
434 | } |
||
435 | } |
||
436 | |||
437 | /** |
||
438 | * Returns the files found under the specified directory and subdirectories. |
||
439 | * @param string $dir the directory under which the files will be looked for. |
||
440 | * @param array $options options for file searching. Valid options are: |
||
441 | * |
||
442 | * - `filter`: callback, a PHP callback that is called for each directory or file. |
||
443 | * The signature of the callback should be: `function ($path)`, where `$path` refers the full path to be filtered. |
||
444 | * The callback can return one of the following values: |
||
445 | * |
||
446 | * * `true`: the directory or file will be returned (the `only` and `except` options will be ignored) |
||
447 | * * `false`: the directory or file will NOT be returned (the `only` and `except` options will be ignored) |
||
448 | * * `null`: the `only` and `except` options will determine whether the directory or file should be returned |
||
449 | * |
||
450 | * - `except`: array, list of patterns excluding from the results matching file or directory paths. |
||
451 | * Patterns ending with slash ('/') apply to directory paths only, and patterns not ending with '/' |
||
452 | * apply to file paths only. For example, '/a/b' matches all file paths ending with '/a/b'; |
||
453 | * and `.svn/` matches directory paths ending with `.svn`. |
||
454 | * If the pattern does not contain a slash (`/`), it is treated as a shell glob pattern |
||
455 | * and checked for a match against the pathname relative to `$dir`. |
||
456 | * Otherwise, the pattern is treated as a shell glob suitable for consumption by `fnmatch(3)` |
||
457 | * with the `FNM_PATHNAME` flag: wildcards in the pattern will not match a `/` in the pathname. |
||
458 | * For example, `views/*.php` matches `views/index.php` but not `views/controller/index.php`. |
||
459 | * A leading slash matches the beginning of the pathname. For example, `/*.php` matches `index.php` but not `views/start/index.php`. |
||
460 | * An optional prefix `!` which negates the pattern; any matching file excluded by a previous pattern will become included again. |
||
461 | * If a negated pattern matches, this will override lower precedence patterns sources. Put a backslash (`\`) in front of the first `!` |
||
462 | * for patterns that begin with a literal `!`, for example, `\!important!.txt`. |
||
463 | * Note, the '/' characters in a pattern matches both '/' and '\' in the paths. |
||
464 | * - `only`: array, list of patterns that the file paths should match if they are to be returned. Directory paths |
||
465 | * are not checked against them. Same pattern matching rules as in the `except` option are used. |
||
466 | * If a file path matches a pattern in both `only` and `except`, it will NOT be returned. |
||
467 | * - `caseSensitive`: boolean, whether patterns specified at `only` or `except` should be case sensitive. Defaults to `true`. |
||
468 | * - `recursive`: boolean, whether the files under the subdirectories should also be looked for. Defaults to `true`. |
||
469 | * @return array files found under the directory, in no particular order. Ordering depends on the files system used. |
||
470 | * @throws InvalidArgumentException if the dir is invalid. |
||
471 | */ |
||
472 | 92 | public static function findFiles($dir, $options = []) |
|
473 | { |
||
474 | 92 | $dir = self::clearDir($dir); |
|
475 | 92 | $options = self::setBasePath($dir, $options); |
|
476 | 92 | $list = []; |
|
477 | 92 | $handle = self::openDir($dir); |
|
478 | 92 | while (($file = readdir($handle)) !== false) { |
|
479 | 92 | if ($file === '.' || $file === '..') { |
|
480 | 92 | continue; |
|
481 | } |
||
482 | 89 | $path = $dir . DIRECTORY_SEPARATOR . $file; |
|
483 | 89 | if (static::filterPath($path, $options)) { |
|
484 | 89 | if (is_file($path)) { |
|
485 | 87 | $list[] = $path; |
|
486 | 25 | } elseif (is_dir($path) && (!isset($options['recursive']) || $options['recursive'])) { |
|
487 | 24 | $list = array_merge($list, static::findFiles($path, $options)); |
|
488 | } |
||
489 | } |
||
490 | } |
||
491 | 92 | closedir($handle); |
|
492 | |||
493 | 92 | return $list; |
|
494 | } |
||
495 | |||
496 | /** |
||
497 | * Returns the directories found under the specified directory and subdirectories. |
||
498 | * @param string $dir the directory under which the files will be looked for. |
||
499 | * @param array $options options for directory searching. Valid options are: |
||
500 | * |
||
501 | * - `filter`: callback, a PHP callback that is called for each directory or file. |
||
502 | * The signature of the callback should be: `function ($path)`, where `$path` refers the full path to be filtered. |
||
503 | * The callback can return one of the following values: |
||
504 | * |
||
505 | * * `true`: the directory will be returned |
||
506 | * * `false`: the directory will NOT be returned |
||
507 | * |
||
508 | * - `recursive`: boolean, whether the files under the subdirectories should also be looked for. Defaults to `true`. |
||
509 | * @return array directories found under the directory, in no particular order. Ordering depends on the files system used. |
||
510 | * @throws InvalidArgumentException if the dir is invalid. |
||
511 | * @since 2.0.14 |
||
512 | */ |
||
513 | 1 | public static function findDirectories($dir, $options = []) |
|
514 | { |
||
515 | 1 | $dir = self::clearDir($dir); |
|
516 | 1 | $options = self::setBasePath($dir, $options); |
|
517 | 1 | $list = []; |
|
518 | 1 | $handle = self::openDir($dir); |
|
519 | 1 | while (($file = readdir($handle)) !== false) { |
|
520 | 1 | if ($file === '.' || $file === '..') { |
|
521 | 1 | continue; |
|
522 | } |
||
523 | 1 | $path = $dir . DIRECTORY_SEPARATOR . $file; |
|
524 | 1 | if (is_dir($path) && static::filterPath($path, $options)) { |
|
525 | 1 | $list[] = $path; |
|
526 | 1 | if (!isset($options['recursive']) || $options['recursive']) { |
|
527 | 1 | $list = array_merge($list, static::findDirectories($path, $options)); |
|
528 | } |
||
529 | } |
||
530 | } |
||
531 | 1 | closedir($handle); |
|
532 | |||
533 | 1 | return $list; |
|
534 | } |
||
535 | |||
536 | /** |
||
537 | * @param string $dir |
||
538 | */ |
||
539 | 93 | private static function setBasePath($dir, $options) |
|
540 | { |
||
541 | 93 | if (!isset($options['basePath'])) { |
|
542 | // this should be done only once |
||
543 | 93 | $options['basePath'] = realpath($dir); |
|
544 | 93 | $options = static::normalizeOptions($options); |
|
545 | } |
||
546 | |||
547 | 93 | return $options; |
|
548 | } |
||
549 | |||
550 | /** |
||
551 | * @param string $dir |
||
552 | */ |
||
553 | 93 | private static function openDir($dir) |
|
554 | { |
||
555 | 93 | $handle = opendir($dir); |
|
556 | 93 | if ($handle === false) { |
|
557 | throw new InvalidArgumentException("Unable to open directory: $dir"); |
||
558 | } |
||
559 | 93 | return $handle; |
|
560 | } |
||
561 | |||
562 | /** |
||
563 | * @param string $dir |
||
564 | */ |
||
565 | 93 | private static function clearDir($dir) |
|
566 | { |
||
567 | 93 | if (!is_dir($dir)) { |
|
568 | throw new InvalidArgumentException("The dir argument must be a directory: $dir"); |
||
569 | } |
||
570 | 93 | return rtrim($dir, DIRECTORY_SEPARATOR); |
|
571 | } |
||
572 | |||
573 | /** |
||
574 | * Checks if the given file path satisfies the filtering options. |
||
575 | * @param string $path the path of the file or directory to be checked |
||
576 | * @param array $options the filtering options. See [[findFiles()]] for explanations of |
||
577 | * the supported options. |
||
578 | * @return bool whether the file or directory satisfies the filtering options. |
||
579 | */ |
||
580 | 99 | public static function filterPath($path, $options) |
|
581 | { |
||
582 | 99 | if (isset($options['filter'])) { |
|
583 | 2 | $result = call_user_func($options['filter'], $path); |
|
584 | 2 | if (is_bool($result)) { |
|
585 | 2 | return $result; |
|
586 | } |
||
587 | } |
||
588 | |||
589 | 98 | if (empty($options['except']) && empty($options['only'])) { |
|
590 | 31 | return true; |
|
591 | } |
||
592 | |||
593 | 76 | $path = str_replace('\\', '/', $path); |
|
594 | |||
595 | 76 | if (!empty($options['except'])) { |
|
596 | 56 | if (($except = self::lastExcludeMatchingFromList($options['basePath'], $path, $options['except'])) !== null) { |
|
597 | 3 | return $except['flags'] & self::PATTERN_NEGATIVE; |
|
598 | } |
||
599 | } |
||
600 | |||
601 | 76 | if (!empty($options['only']) && !is_dir($path)) { |
|
602 | 74 | if (($except = self::lastExcludeMatchingFromList($options['basePath'], $path, $options['only'])) !== null) { |
|
603 | // don't check PATTERN_NEGATIVE since those entries are not prefixed with ! |
||
604 | 72 | return true; |
|
605 | } |
||
606 | |||
607 | 20 | return false; |
|
608 | } |
||
609 | |||
610 | 20 | return true; |
|
611 | } |
||
612 | |||
613 | /** |
||
614 | * Creates a new directory. |
||
615 | * |
||
616 | * This method is similar to the PHP `mkdir()` function except that |
||
617 | * it uses `chmod()` to set the permission of the created directory |
||
618 | * in order to avoid the impact of the `umask` setting. |
||
619 | * |
||
620 | * @param string $path path of the directory to be created. |
||
621 | * @param int $mode the permission to be set for the created directory. |
||
622 | * @param bool $recursive whether to create parent directories if they do not exist. |
||
623 | * @return bool whether the directory is created successfully |
||
624 | * @throws \yii\base\Exception if the directory could not be created (i.e. php error due to parallel changes) |
||
625 | */ |
||
626 | 200 | public static function createDirectory($path, $mode = 0775, $recursive = true) |
|
627 | { |
||
628 | 200 | if (is_dir($path)) { |
|
629 | 86 | return true; |
|
630 | } |
||
631 | 179 | $parentDir = dirname($path); |
|
632 | // recurse if parent dir does not exist and we are not at the root of the file system. |
||
633 | 179 | if ($recursive && !is_dir($parentDir) && $parentDir !== $path) { |
|
634 | 5 | static::createDirectory($parentDir, $mode, true); |
|
635 | } |
||
636 | try { |
||
637 | 179 | if (!mkdir($path, $mode)) { |
|
638 | 179 | return false; |
|
639 | } |
||
640 | } catch (\Exception $e) { |
||
641 | if (!is_dir($path)) {// https://github.com/yiisoft/yii2/issues/9288 |
||
642 | throw new \yii\base\Exception("Failed to create directory \"$path\": " . $e->getMessage(), $e->getCode(), $e); |
||
643 | } |
||
644 | } |
||
645 | try { |
||
646 | 179 | return chmod($path, $mode); |
|
647 | } catch (\Exception $e) { |
||
648 | throw new \yii\base\Exception("Failed to change permissions for directory \"$path\": " . $e->getMessage(), $e->getCode(), $e); |
||
649 | } |
||
650 | } |
||
651 | |||
652 | /** |
||
653 | * Performs a simple comparison of file or directory names. |
||
654 | * |
||
655 | * Based on match_basename() from dir.c of git 1.8.5.3 sources. |
||
656 | * |
||
657 | * @param string $baseName file or directory name to compare with the pattern |
||
658 | * @param string $pattern the pattern that $baseName will be compared against |
||
659 | * @param int|bool $firstWildcard location of first wildcard character in the $pattern |
||
660 | * @param int $flags pattern flags |
||
661 | * @return bool whether the name matches against pattern |
||
662 | */ |
||
663 | 75 | private static function matchBasename($baseName, $pattern, $firstWildcard, $flags) |
|
664 | { |
||
665 | 75 | if ($firstWildcard === false) { |
|
666 | 64 | if ($pattern === $baseName) { |
|
667 | 64 | return true; |
|
668 | } |
||
669 | 64 | } elseif ($flags & self::PATTERN_ENDSWITH) { |
|
670 | /* "*literal" matching against "fooliteral" */ |
||
671 | 63 | $n = StringHelper::byteLength($pattern); |
|
672 | 63 | if (StringHelper::byteSubstr($pattern, 1, $n) === StringHelper::byteSubstr($baseName, -$n, $n)) { |
|
673 | return true; |
||
674 | } |
||
675 | } |
||
676 | |||
677 | 75 | $matchOptions = []; |
|
678 | 75 | if ($flags & self::PATTERN_CASE_INSENSITIVE) { |
|
679 | 1 | $matchOptions['caseSensitive'] = false; |
|
680 | } |
||
681 | |||
682 | 75 | return StringHelper::matchWildcard($pattern, $baseName, $matchOptions); |
|
683 | } |
||
684 | |||
685 | /** |
||
686 | * Compares a path part against a pattern with optional wildcards. |
||
687 | * |
||
688 | * Based on match_pathname() from dir.c of git 1.8.5.3 sources. |
||
689 | * |
||
690 | * @param string $path full path to compare |
||
691 | * @param string $basePath base of path that will not be compared |
||
692 | * @param string $pattern the pattern that path part will be compared against |
||
693 | * @param int|bool $firstWildcard location of first wildcard character in the $pattern |
||
694 | * @param int $flags pattern flags |
||
695 | * @return bool whether the path part matches against pattern |
||
696 | */ |
||
697 | 57 | private static function matchPathname($path, $basePath, $pattern, $firstWildcard, $flags) |
|
698 | { |
||
699 | // match with FNM_PATHNAME; the pattern has base implicitly in front of it. |
||
700 | 57 | if (isset($pattern[0]) && $pattern[0] === '/') { |
|
701 | 54 | $pattern = StringHelper::byteSubstr($pattern, 1, StringHelper::byteLength($pattern)); |
|
702 | 54 | if ($firstWildcard !== false && $firstWildcard !== 0) { |
|
703 | $firstWildcard--; |
||
704 | } |
||
705 | } |
||
706 | |||
707 | 57 | $namelen = StringHelper::byteLength($path) - (empty($basePath) ? 0 : StringHelper::byteLength($basePath) + 1); |
|
708 | 57 | $name = StringHelper::byteSubstr($path, -$namelen, $namelen); |
|
709 | |||
710 | 57 | if ($firstWildcard !== 0) { |
|
711 | 57 | if ($firstWildcard === false) { |
|
712 | 56 | $firstWildcard = StringHelper::byteLength($pattern); |
|
713 | } |
||
714 | // if the non-wildcard part is longer than the remaining pathname, surely it cannot match. |
||
715 | 57 | if ($firstWildcard > $namelen) { |
|
716 | 2 | return false; |
|
717 | } |
||
718 | |||
719 | 57 | if (strncmp($pattern, $name, $firstWildcard)) { |
|
720 | 57 | return false; |
|
721 | } |
||
722 | 4 | $pattern = StringHelper::byteSubstr($pattern, $firstWildcard, StringHelper::byteLength($pattern)); |
|
723 | 4 | $name = StringHelper::byteSubstr($name, $firstWildcard, $namelen); |
|
724 | |||
725 | // If the whole pattern did not have a wildcard, then our prefix match is all we need; we do not need to call fnmatch at all. |
||
726 | 4 | if (empty($pattern) && empty($name)) { |
|
727 | 3 | return true; |
|
728 | } |
||
729 | } |
||
730 | |||
731 | $matchOptions = [ |
||
732 | 2 | 'filePath' => true |
|
733 | ]; |
||
734 | 2 | if ($flags & self::PATTERN_CASE_INSENSITIVE) { |
|
735 | $matchOptions['caseSensitive'] = false; |
||
736 | } |
||
737 | |||
738 | 2 | return StringHelper::matchWildcard($pattern, $name, $matchOptions); |
|
739 | } |
||
740 | |||
741 | /** |
||
742 | * Scan the given exclude list in reverse to see whether pathname |
||
743 | * should be ignored. The first match (i.e. the last on the list), if |
||
744 | * any, determines the fate. Returns the element which |
||
745 | * matched, or null for undecided. |
||
746 | * |
||
747 | * Based on last_exclude_matching_from_list() from dir.c of git 1.8.5.3 sources. |
||
748 | * |
||
749 | * @param string $basePath |
||
750 | * @param string $path |
||
751 | * @param array $excludes list of patterns to match $path against |
||
752 | * @return array|null null or one of $excludes item as an array with keys: 'pattern', 'flags' |
||
753 | * @throws InvalidArgumentException if any of the exclude patterns is not a string or an array with keys: pattern, flags, firstWildcard. |
||
754 | */ |
||
755 | 76 | private static function lastExcludeMatchingFromList($basePath, $path, $excludes) |
|
756 | { |
||
757 | 76 | foreach (array_reverse($excludes) as $exclude) { |
|
758 | 76 | if (is_string($exclude)) { |
|
759 | $exclude = self::parseExcludePattern($exclude, false); |
||
760 | } |
||
761 | 76 | if (!isset($exclude['pattern']) || !isset($exclude['flags']) || !isset($exclude['firstWildcard'])) { |
|
762 | throw new InvalidArgumentException('If exclude/include pattern is an array it must contain the pattern, flags and firstWildcard keys.'); |
||
763 | } |
||
764 | 76 | if ($exclude['flags'] & self::PATTERN_MUSTBEDIR && !is_dir($path)) { |
|
765 | continue; |
||
766 | } |
||
767 | |||
768 | 76 | if ($exclude['flags'] & self::PATTERN_NODIR) { |
|
769 | 75 | if (self::matchBasename(basename($path), $exclude['pattern'], $exclude['firstWildcard'], $exclude['flags'])) { |
|
770 | 73 | return $exclude; |
|
771 | } |
||
772 | 74 | continue; |
|
773 | } |
||
774 | |||
775 | 57 | if (self::matchPathname($path, $basePath, $exclude['pattern'], $exclude['firstWildcard'], $exclude['flags'])) { |
|
776 | 57 | return $exclude; |
|
777 | } |
||
778 | } |
||
779 | |||
780 | 75 | return null; |
|
781 | } |
||
782 | |||
783 | /** |
||
784 | * Processes the pattern, stripping special characters like / and ! from the beginning and settings flags instead. |
||
785 | * @param string $pattern |
||
786 | * @param bool $caseSensitive |
||
787 | * @throws InvalidArgumentException |
||
788 | * @return array with keys: (string) pattern, (int) flags, (int|bool) firstWildcard |
||
789 | */ |
||
790 | 82 | private static function parseExcludePattern($pattern, $caseSensitive) |
|
791 | { |
||
792 | 82 | if (!is_string($pattern)) { |
|
793 | throw new InvalidArgumentException('Exclude/include pattern must be a string.'); |
||
794 | } |
||
795 | |||
796 | $result = [ |
||
797 | 82 | 'pattern' => $pattern, |
|
798 | 82 | 'flags' => 0, |
|
799 | 'firstWildcard' => false, |
||
800 | ]; |
||
801 | |||
802 | 82 | if (!$caseSensitive) { |
|
803 | 1 | $result['flags'] |= self::PATTERN_CASE_INSENSITIVE; |
|
804 | } |
||
805 | |||
806 | 82 | if (!isset($pattern[0])) { |
|
807 | return $result; |
||
808 | } |
||
809 | |||
810 | 82 | if ($pattern[0] === '!') { |
|
811 | $result['flags'] |= self::PATTERN_NEGATIVE; |
||
812 | $pattern = StringHelper::byteSubstr($pattern, 1, StringHelper::byteLength($pattern)); |
||
813 | } |
||
814 | 82 | if (StringHelper::byteLength($pattern) && StringHelper::byteSubstr($pattern, -1, 1) === '/') { |
|
815 | $pattern = StringHelper::byteSubstr($pattern, 0, -1); |
||
816 | $result['flags'] |= self::PATTERN_MUSTBEDIR; |
||
817 | } |
||
818 | 82 | if (strpos($pattern, '/') === false) { |
|
819 | 81 | $result['flags'] |= self::PATTERN_NODIR; |
|
820 | } |
||
821 | 82 | $result['firstWildcard'] = self::firstWildcardInPattern($pattern); |
|
822 | 82 | if ($pattern[0] === '*' && self::firstWildcardInPattern(StringHelper::byteSubstr($pattern, 1, StringHelper::byteLength($pattern))) === false) { |
|
823 | 69 | $result['flags'] |= self::PATTERN_ENDSWITH; |
|
824 | } |
||
825 | 82 | $result['pattern'] = $pattern; |
|
826 | |||
827 | 82 | return $result; |
|
828 | } |
||
829 | |||
830 | /** |
||
831 | * Searches for the first wildcard character in the pattern. |
||
832 | * @param string $pattern the pattern to search in |
||
833 | * @return int|bool position of first wildcard character or false if not found |
||
834 | */ |
||
835 | 82 | private static function firstWildcardInPattern($pattern) |
|
836 | { |
||
837 | 82 | $wildcards = ['*', '?', '[', '\\']; |
|
838 | 82 | $wildcardSearch = function ($r, $c) use ($pattern) { |
|
839 | 82 | $p = strpos($pattern, $c); |
|
840 | |||
841 | 82 | return $r === false ? $p : ($p === false ? $r : min($r, $p)); |
|
842 | 82 | }; |
|
843 | |||
844 | 82 | return array_reduce($wildcards, $wildcardSearch, false); |
|
845 | } |
||
846 | |||
847 | /** |
||
848 | * @param array $options raw options |
||
849 | * @return array normalized options |
||
850 | * @since 2.0.12 |
||
851 | */ |
||
852 | 104 | protected static function normalizeOptions(array $options) |
|
853 | { |
||
854 | 104 | if (!array_key_exists('caseSensitive', $options)) { |
|
855 | 103 | $options['caseSensitive'] = true; |
|
856 | } |
||
857 | 104 | if (isset($options['except'])) { |
|
858 | 62 | foreach ($options['except'] as $key => $value) { |
|
859 | 62 | if (is_string($value)) { |
|
860 | 62 | $options['except'][$key] = self::parseExcludePattern($value, $options['caseSensitive']); |
|
861 | } |
||
862 | } |
||
863 | } |
||
864 | 104 | if (isset($options['only'])) { |
|
865 | 80 | foreach ($options['only'] as $key => $value) { |
|
866 | 80 | if (is_string($value)) { |
|
867 | 80 | $options['only'][$key] = self::parseExcludePattern($value, $options['caseSensitive']); |
|
868 | } |
||
869 | } |
||
870 | } |
||
871 | |||
872 | 104 | return $options; |
|
873 | } |
||
874 | } |
||
875 |