BaseFileHelper.php 43 KB

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