Files
espocrm/application/Espo/Core/Utils/File/Manager.php
Yuri Kuznetsov e116c52ea9 refactoring
2021-04-24 12:58:51 +03:00

1089 lines
29 KiB
PHP

<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM - Open Source CRM application.
* Copyright (C) 2014-2021 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
* Website: https://www.espocrm.com
*
* EspoCRM is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* EspoCRM is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with EspoCRM. If not, see http://www.gnu.org/licenses/.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU General Public License version 3.
*
* In accordance with Section 7(b) of the GNU General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\File;
use Espo\Core\{
Exceptions\Error,
Utils\Util,
Utils\Json,
};
use Exception;
use StdClass;
use Throwable;
class Manager
{
private $permission;
private $permissionDeniedList = [];
protected $tmpDir = 'data/tmp';
const RENAME_RETRY_NUMBER = 10;
const RENAME_RETRY_INTERVAL = 0.1;
const GET_SAFE_CONTENTS_RETRY_NUMBER = 10;
const GET_SAFE_CONTENTS_RETRY_INTERVAL = 0.1;
public function __construct(?array $defaultPermissions = null)
{
$params = null;
if ($defaultPermissions) {
$params = [
'defaultPermissions' => $defaultPermissions,
];
}
$this->permission = new Permission($this, $params);
}
public function getPermissionUtils()
{
return $this->permission;
}
/**
* Get a list of files in specified directory.
*
* @param string $path string - Folder path.
* @param bool | int $recursively - Find files in subfolders.
* @param string $filter - Filter for files. Use regular expression, Ex. \.json$.
* @param bool $onlyFileType [null, true, false] - Filter for type of files/directories.
* If TRUE - returns only file list, if FALSE - only directory list.
* @param bool $isReturnSingleArray - if need to return a single array of file list.
*
* @return array
*/
public function getFileList(
$path,
$recursively = false,
$filter = '',
$onlyFileType = null,
$isReturnSingleArray = false
) {
$path = $this->concatPaths($path);
$result = [];
if (!file_exists($path) || !is_dir($path)) {
return $result;
}
$cdir = scandir($path);
foreach ($cdir as $key => $value) {
if (!in_array($value, [".", ".."])) {
$add = false;
if (is_dir($path . Util::getSeparator() . $value)) {
if ($recursively || (is_int($recursively) && $recursively!=0) ) {
$nextRecursively = is_int($recursively) ? ($recursively-1) : $recursively;
$result[$value] = $this->getFileList(
$path . Util::getSeparator() . $value, $nextRecursively, $filter, $onlyFileType
);
}
else if (!isset($onlyFileType) || !$onlyFileType){ /*save only directories*/
$add = true;
}
}
else if (!isset($onlyFileType) || $onlyFileType) { /*save only files*/
$add = true;
}
if ($add) {
if (!empty($filter)) {
if (preg_match('/'.$filter.'/i', $value)) {
$result[] = $value;
}
}
else {
$result[] = $value;
}
}
}
}
if ($isReturnSingleArray) {
return $this->getSingleFileList($result, $onlyFileType, $path);
}
return $result;
}
protected function getSingleFileList(array $fileList, $onlyFileType = null, $basePath = null, $parentDirName = '')
{
$singleFileList = [];
foreach ($fileList as $dirName => $fileName) {
if (is_array($fileName)) {
$currentDir = Util::concatPath($parentDirName, $dirName);
if (!isset($onlyFileType) || $onlyFileType == $this->isFilenameIsFile($currentDir, $basePath)) {
$singleFileList[] = $currentDir;
}
$singleFileList = array_merge(
$singleFileList, $this->getSingleFileList($fileName, $onlyFileType, $basePath, $currentDir)
);
} else {
$currentFileName = Util::concatPath($parentDirName, $fileName);
if (!isset($onlyFileType) || $onlyFileType == $this->isFilenameIsFile($currentFileName, $basePath)) {
$singleFileList[] = $currentFileName;
}
}
}
return $singleFileList;
}
/**
* Reads entire file into a string.
*
* @param string | array $path Ex. 'path.php' OR array('dir', 'path.php').
* @return mixed
*/
public function getContents($path)
{
$fullPath = $this->concatPaths($path);
if (file_exists($fullPath)) {
return file_get_contents($fullPath);
}
return false;
}
/**
* Get data from PHP file.
*
* @param string|array $path
* @return mixed|bool
*/
public function getPhpContents($path)
{
$fullPath = $this->concatPaths($path);
if (file_exists($fullPath) && strtolower(substr($fullPath, -4)) == '.php') {
$phpContents = include($fullPath);
return $phpContents;
}
return false;
}
/**
* Get array or StdClass data from PHP file.
* If a file is not yet written, it will wait until it's ready.
*
* @return array|StdClass
*/
public function getPhpSafeContents(string $path)
{
if (!file_exists($path)) {
throw new Error("Can't get contents from non-existing file '{$path}'.");
}
if (!strtolower(substr($path, -4)) == '.php') {
throw new Error("Only PHP file are allowed for getting contents.");
}
$counter = 0;
while ($counter < self::GET_SAFE_CONTENTS_RETRY_NUMBER) {
$data = include($path);
if (is_array($data) || $data instanceof StdClass) {
return $data;
}
usleep(self::GET_SAFE_CONTENTS_RETRY_INTERVAL * 1000000);
$counter ++;
}
throw new Error("Bad data stored in file '{$path}'.");
}
/**
* Write data to a file.
*
* @param string | array $path
* @param mixed $data
* @param integer $flags
*
* @return bool
*/
public function putContents($path, $data, $flags = 0, bool $useRenaming = false)
{
$fullPath = $this->concatPaths($path); // @todo remove after changing the params
if ($this->checkCreateFile($fullPath) === false) {
throw new Error('Permission denied for '. $fullPath);
}
$result = false;
if ($useRenaming) {
$result = $this->putContentsUseRenaming($fullPath, $data);
}
if (!$result) {
$result = (file_put_contents($fullPath, $data, $flags) !== FALSE);
}
if ($result) {
$this->opcacheInvalidate($fullPath);
}
return $result;
}
protected function putContentsUseRenaming($path, $data)
{
$tmpDir = $this->tmpDir;
if (!$this->isDir($tmpDir)) {
$this->mkdir($tmpDir);
}
if (!$this->isDir($tmpDir)) {
return false;
}
$tmpPath = tempnam($tmpDir, 'tmp');
$tmpPath = $this->getRelativePath($tmpPath);
if (!$tmpPath) {
return false;
}
if (!$this->isFile($tmpPath)) {
return false;
}
if (!$this->isWritable($tmpPath)) {
return false;
}
$h = fopen($tmpPath, 'w');
fwrite($h, $data);
fclose($h);
$this->getPermissionUtils()->setDefaultPermissions($tmpPath);
if (!$this->isReadable($tmpPath)) {
return false;
}
$result = rename($tmpPath, $path);
if (!$result && stripos(\PHP_OS, 'WIN') === 0) {
$result = $this->renameInLoop($tmpPath, $path);
}
if ($this->isFile($tmpPath)) {
$this->removeFile($tmpPath);
}
return $result;
}
protected function renameInLoop(string $source, string $destination) : bool
{
$counter = 0;
while ($counter < self::RENAME_RETRY_NUMBER) {
if (!$this->isWritable($destination)) {
break;
}
$result = rename($source, $destination);
if ($result !== false) {
return true;
}
usleep(self::RENAME_RETRY_INTERVAL * 1000000);
$counter ++;
}
return false;
}
/**
* Save PHP content to file.
*
* @return bool
*/
public function putPhpContents(string $path, $data, bool $withObjects = false, bool $useRenaming = false)
{
return $this->putContents($path, $this->wrapForDataExport($data, $withObjects), LOCK_EX, $useRenaming);
}
/**
* Save JSON content to file.
*
* @param string|array $path
* @param mixed $data
* @param integer $flags
* @param resource $context
*
* @return bool
*/
public function putContentsJson($path, $data)
{
$options = JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
$contents = Json::encode($data, $options);
return $this->putContents($path, $contents, LOCK_EX);
}
/**
* Merge JSON file contents with existing and override the file.
*/
public function mergeJsonContents(string $path, array $data): bool
{
$currentContents = $this->getContents($path);
if ($this->isFile($path) && $currentContents === false) {
throw new Error("FileManager: Failed to read file '{$path}'.");
}
$currentData = $this->isFile($path) ?
Json::decode($currentContents, true):
[];
$mergedData = Util::merge($currentData, $data);
$jsonOptions = JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
$stringData = Json::encode($mergedData, $jsonOptions);
return (bool) $this->putContents($path, $stringData);
}
/**
* Append the content to the end of the file.
*
* @param string | array $path
* @param mixed $data
*
* @return bool
*/
public function appendContents($path, $data)
{
return $this->putContents($path, $data, FILE_APPEND | LOCK_EX);
}
/**
* Unset specific items in a JSON file and override the file.
* Items are specified as an array of JSON paths.
*/
public function unsetJsonContents(string $path, array $unsets): bool
{
if (!file_exists($path)) {
return true;
}
$currentContents = $this->getContents($path);
if (!isset($currentContents) || !$currentContents) {
return true;
}
$currentData = Json::decode($currentContents, true);
$unsettedData = Util::unsetInArray($currentData, $unsets, true);
if (
is_null($unsettedData) ||
(is_array($unsettedData) && empty($unsettedData))
) {
return $this->unlink($path);
}
return (bool) $this->putContentsJson($path, $unsettedData);
}
/**
* Concat paths.
* @param string | array $paths Ex. array('pathPart1', 'pathPart2', 'pathPart3')
* @return string
*/
protected function concatPaths($paths)
{
if (is_string($paths)) {
return Util::fixPath($paths);
}
$fullPath = '';
foreach ($paths as $path) {
$fullPath = Util::concatPath($fullPath, $path);
}
return $fullPath;
}
/**
* Create a new dir.
*
* @param string | array $path
* @param int $permission - ex. 0755
*
* @return bool
*/
public function mkdir($path, $permission = null)
{
$fullPath = $this->concatPaths($path);
if (file_exists($fullPath) && is_dir($fullPath)) {
return true;
}
$parentDirPath = dirname($fullPath);
if (!file_exists($parentDirPath)) {
$this->mkdir($parentDirPath, $permission);
}
$defaultPermissions = $this->getPermissionUtils()->getRequiredPermissions($fullPath);
if (!isset($permission)) {
$permission = (string) $defaultPermissions['dir'];
$permission = base_convert($permission, 8, 10);
}
try {
$umask = umask(0);
$result = mkdir($fullPath, $permission);
if ($umask) {
umask($umask);
}
if (!empty($defaultPermissions['user'])) {
$this->getPermissionUtils()->chown($fullPath);
}
if (!empty($defaultPermissions['group'])) {
$this->getPermissionUtils()->chgrp($fullPath);
}
}
catch (Exception $e) {
if (isset($GLOBALS['log'])) {
$GLOBALS['log']->critical(
'Permission denied: unable to create the folder on the server: ' . $fullPath
);
}
}
return isset($result) ? $result : false;
}
/**
* Copy files from one direcoty to another.
* Ex. $sourcePath = 'data/uploads/extensions/file.json',
* $destPath = 'data/uploads/backup', result will be data/uploads/backup/data/uploads/backup/file.json.
*
* @param string $sourcePath
* @param string $destPath
* @param boolean $recursively
* @param array $fileList - list of files that should be copied
* @param boolean $copyOnlyFiles - copy only files, instead of full path with directories,
* Ex. $sourcePath = 'data/uploads/extensions/file.json',
* $destPath = 'data/uploads/backup', result will be 'data/uploads/backup/file.json'
* @return boolen
*/
public function copy($sourcePath, $destPath, $recursively = false, array $fileList = null, $copyOnlyFiles = false)
{
$sourcePath = $this->concatPaths($sourcePath);
$destPath = $this->concatPaths($destPath);
if (!isset($fileList)) {
$fileList = is_file($sourcePath) ?
(array) $sourcePath :
$this->getFileList($sourcePath, $recursively, '', true, true);
}
$permissionDeniedList = [];
foreach ($fileList as $file) {
if ($copyOnlyFiles) {
$file = pathinfo($file, PATHINFO_BASENAME);
}
$destFile = $this->concatPaths([$destPath, $file]);
$isFileExists = file_exists($destFile);
if ($this->checkCreateFile($destFile) === false) {
$permissionDeniedList[] = $destFile;
} else if (!$isFileExists) {
$this->removeFile($destFile);
}
}
if (!empty($permissionDeniedList)) {
$betterPermissionList = $this->getPermissionUtils()->arrangePermissionList($permissionDeniedList);
throw new Error("Permission denied for <br>". implode(", <br>", $betterPermissionList));
}
$res = true;
foreach ($fileList as $file) {
if ($copyOnlyFiles) {
$file = pathinfo($file, PATHINFO_BASENAME);
}
$sourceFile = is_file($sourcePath) ? $sourcePath : $this->concatPaths([$sourcePath, $file]);
$destFile = $this->concatPaths([$destPath, $file]);
if (file_exists($sourceFile) && is_file($sourceFile)) {
$res &= copy($sourceFile, $destFile);
$this->getPermissionUtils()->setDefaultPermissions($destFile);
$this->opcacheInvalidate($destFile);
}
}
return $res;
}
/**
* Create a new file if not exists with all folders in the path.
*
* @param string $filePath
*/
public function checkCreateFile($filePath)
{
$defaultPermissions = $this->getPermissionUtils()->getRequiredPermissions($filePath);
if (file_exists($filePath)) {
if (
!is_writable($filePath) &&
!in_array(
$this->getPermissionUtils()->getCurrentPermission($filePath),
[$defaultPermissions['file'], $defaultPermissions['dir']]
)
) {
return $this->getPermissionUtils()->setDefaultPermissions($filePath);
}
return true;
}
$pathParts = pathinfo($filePath);
if (!file_exists($pathParts['dirname'])) {
$dirPermission = $defaultPermissions['dir'];
$dirPermission = is_string($dirPermission) ? base_convert($dirPermission,8,10) : $dirPermission;
if (!$this->mkdir($pathParts['dirname'], $dirPermission, true)) {
throw new Error(
'Permission denied: unable to create a folder on the server ' . $pathParts['dirname']
);
}
}
if (touch($filePath)) {
return $this->getPermissionUtils()->setDefaultPermissions($filePath);
}
return false;
}
/**
* Remove file/files by given path.
*
* @param array $filePaths - File paths list.
* @return bool
*/
public function unlink($filePaths)
{
return $this->removeFile($filePaths);
}
public function rmdir($dirPaths)
{
if (!is_array($dirPaths)) {
$dirPaths = (array) $dirPaths;
}
$result = true;
foreach ($dirPaths as $dirPath) {
if (is_dir($dirPath) && is_writable($dirPath)) {
$result &= rmdir($dirPath);
}
}
return (bool) $result;
}
public function removeDir($dirPaths)
{
return $this->rmdir($dirPaths);
}
/**
* Remove file/files by given path.
*
* @param array $filePaths - File paths list.
* @param string $dirPath - directory path.
* @return bool
*/
public function removeFile($filePaths, $dirPath = null)
{
if (!is_array($filePaths)) {
$filePaths = (array) $filePaths;
}
$result = true;
foreach ($filePaths as $filePath) {
if (isset($dirPath)) {
$filePath = Util::concatPath($dirPath, $filePath);
}
if (file_exists($filePath) && is_file($filePath)) {
$this->opcacheInvalidate($filePath, true);
$result &= unlink($filePath);
}
}
return $result;
}
/**
* Remove all files inside given path.
*
* @param string $dirPath - directory path.
* @param bool $removeWithDir - if remove with directory.
* @return bool
*/
public function removeInDir($dirPath, $removeWithDir = false)
{
$fileList = $this->getFileList($dirPath, false);
$result = true;
if (is_array($fileList)) {
foreach ($fileList as $file) {
$fullPath = Util::concatPath($dirPath, $file);
if (is_dir($fullPath)) {
$result &= $this->removeInDir($fullPath, true);
}
else if (file_exists($fullPath)) {
$result &= unlink($fullPath);
}
}
}
if ($removeWithDir && $this->isDirEmpty($dirPath)) {
$result &= $this->rmdir($dirPath);
}
return (bool) $result;
}
/**
* Remove items (files or directories).
*
* @param string | array $items
* @param string $dirPath
* @return boolean
*/
public function remove($items, $dirPath = null, $removeEmptyDirs = false)
{
if (!is_array($items)) {
$items = (array) $items;
}
$removeList = [];
$permissionDeniedList = [];
foreach ($items as $item) {
if (isset($dirPath)) {
$item = Util::concatPath($dirPath, $item);
}
if (!file_exists($item)) {
continue;
}
$removeList[] = $item;
if (!is_writable($item)) {
$permissionDeniedList[] = $item;
} else if (!is_writable(dirname($item))) {
$permissionDeniedList[] = dirname($item);
}
}
if (!empty($permissionDeniedList)) {
$betterPermissionList = $this->getPermissionUtils()->arrangePermissionList($permissionDeniedList);
throw new Error("Permission denied for <br>". implode(", <br>", $betterPermissionList));
}
$result = true;
foreach ($removeList as $item) {
if (is_dir($item)) {
$result &= $this->removeInDir($item, true);
} else {
$result &= $this->removeFile($item);
}
if ($removeEmptyDirs) {
$result &= $this->removeEmptyDirs($item);
}
}
return (bool) $result;
}
/**
* Remove empty parent directories if they are empty.
* @param string $path
* @return bool
*/
protected function removeEmptyDirs($path)
{
$parentDirName = $this->getParentDirName($path);
$res = true;
if ($this->isDirEmpty($parentDirName)) {
$res &= $this->rmdir($parentDirName);
$res &= $this->removeEmptyDirs($parentDirName);
}
return (bool) $res;
}
/**
* Check whether a path is a directory.
*/
public function isDir(string $dirPath) : bool
{
return is_dir($dirPath);
}
/**
* Check whether a path is a file.
*/
public function isFile(string $filePath) : bool
{
return is_file($filePath);
}
/**
* Check if $filename is file. If $filename doesn'ot exist, check by pathinfo.
*
* @param string $filename
* @param string $basePath
*
* @return boolean
*/
public function isFilenameIsFile($filename, $basePath = null)
{
if (!empty($basePath)) {
$filename = $this->concatPaths([$basePath, $filename]);
}
if (file_exists($filename)) {
return is_file($filename);
}
$fileExtension = pathinfo($filename, PATHINFO_EXTENSION);
if (!empty($fileExtension)) {
return true;
}
return false;
}
/**
* Check whether a directory is empty.
*/
public function isDirEmpty(string $path) : bool
{
if (is_dir($path)) {
$fileList = $this->getFileList($path, true);
if (is_array($fileList) && empty($fileList)) {
return true;
}
}
return false;
}
/**
* Get a filename without the file extension.
*
* @param string $fileName
* @param string $ext Extension, ex. `.json`.
*
* @return array
*/
public function getFileName($fileName, $ext = '')
{
if (empty($ext)) {
$fileName= substr($fileName, 0, strrpos($fileName, '.', -1));
}
else {
if (substr($ext, 0, 1)!='.') {
$ext= '.'.$ext;
}
if (substr($fileName, -(strlen($ext)))==$ext) {
$fileName= substr($fileName, 0, -(strlen($ext)));
}
}
$exFileName = explode('/', Util::toFormat($fileName, '/'));
return end($exFileName);
}
/**
* Get a directory name from the path.
*
* @param string $path
* @param bool $isFullPath
*
* @return array
*/
public function getDirName($path, $isFullPath = true, $useIsDir = true)
{
$dirName = preg_replace('/\/$/i', '', $path);
$dirName = ($useIsDir && is_dir($dirName)) ? $dirName : pathinfo($dirName, PATHINFO_DIRNAME);
if (!$isFullPath) {
$pieces = explode('/', $dirName);
$dirName = $pieces[count($pieces)-1];
}
return $dirName;
}
/**
* Get parent dir name/path.
*
* @param string $path
* @param boolean $isFullPath
* @return string
*/
public function getParentDirName($path, $isFullPath = true)
{
return $this->getDirName($path, $isFullPath, false);
}
/**
* Return content of PHP file.
*
* @param array $content
*
* @return string | false
*/
public function wrapForDataExport($content, $withObjects = false)
{
if (!isset($content)) {
return false;
}
if (!$withObjects) {
return "<?php\n" .
"return " . var_export($content, true) . ";\n";
}
return "<?php\n" .
"return " . $this->varExport($content) . ";\n";
}
protected function varExport($variable, int $level = 0)
{
$tab = '';
$tabElement = ' ';
for ($i = 0; $i <= $level; $i++) {
$tab .= $tabElement;
}
$prevTab = substr($tab, 0, strlen($tab) - strlen($tabElement));
if ($variable instanceof StdClass) {
return "(object) " . $this->varExport(get_object_vars($variable), $level);
}
if (is_array($variable)) {
$array = [];
foreach ($variable as $key => $value) {
$array[] = var_export($key, true) . " => " . $this->varExport($value, $level + 1);
}
if (count($array) === 0) {
return "[]";
}
return "[\n" . $tab . implode(",\n" . $tab, $array) . "\n" . $prevTab . "]";
}
return var_export($variable, true);
}
/**
* Check if $paths are writable. Permission denied list are defined in getLastPermissionDeniedList().
*/
public function isWritableList(array $paths) : bool
{
$permissionDeniedList = [];
$result = true;
foreach ($paths as $path) {
$rowResult = $this->isWritable($path);
if (!$rowResult) {
$permissionDeniedList[] = $path;
}
$result &= $rowResult;
}
if (!empty($permissionDeniedList)) {
$this->permissionDeniedList =
$this->getPermissionUtils()->arrangePermissionList($permissionDeniedList);
}
return (bool) $result;
}
/**
* Get last permission denied list.
*
* @return array
*/
public function getLastPermissionDeniedList()
{
return $this->permissionDeniedList;
}
/**
* Check if $path is writable.
*/
public function isWritable(string $path) : bool
{
$existFile = $this->getExistsPath($path);
return is_writable($existFile);
}
/**
* Check if $path is writable.
*/
public function isReadable(string $path) : bool
{
$existFile = $this->getExistsPath($path);
return is_readable($existFile);
}
/**
* Get exists path.
* Ex. if check /var/www/espocrm/custom/someFile.php and this file doesn't extist,
* result will be /var/www/espocrm/custom
*
* @param string | array $path
* @return string
*/
protected function getExistsPath($path)
{
$fullPath = $this->concatPaths($path);
if (!file_exists($fullPath)) {
$fullPath = $this->getExistsPath(pathinfo($fullPath, PATHINFO_DIRNAME));
}
return $fullPath;
}
public function getRelativePath($path, $basePath = null, $dirSeparator = null)
{
if (!$basePath) {
$basePath = getcwd();
}
$path = Util::fixPath($path);
$basePath = Util::fixPath($basePath);
if (!$dirSeparator) {
$dirSeparator = Util::getSeparator();
}
if (substr($basePath, -1) != $dirSeparator) {
$basePath .= $dirSeparator;
}
return preg_replace('/^'. preg_quote($basePath, $dirSeparator) . '/', '', $path);
}
protected function opcacheInvalidate(string $filepath, bool $force = false)
{
if (!function_exists('opcache_invalidate')) {
return;
}
try {
opcache_invalidate($filepath, $force);
}
catch (Throwable $e) {}
}
}