<?php /************************************************************************ * This file is part of EspoCRM. * * EspoCRM – Open Source CRM application. * Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko * Website: https://www.espocrm.com * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program 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 Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see <https://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 Affero General Public License version 3. * * In accordance with Section 7(b) of the GNU Affero 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\Utils\Util; use Throwable; class Permission { /** * Last permission error. * * @var string[] */ protected $permissionError = []; /** * @var ?array<string, mixed> */ protected $permissionErrorRules = null; /** * @var array<string, array<string, mixed>> */ protected $writableMap = [ 'data' => [ 'recursive' => true, ], 'application/Espo/Modules' => [ 'recursive' => false, ], 'client/custom' => [ 'recursive' => true, ], 'client/modules' => [ 'recursive' => false, ], 'custom/Espo/Custom' => [ 'recursive' => true, ], ]; /** * @var array{ * dir: string|int|null, * file: string|int|null, * user: string|int|null, * group: string|int|null, * } */ protected $defaultPermissions = [ 'dir' => '0755', 'file' => '0644', 'user' => null, 'group' => null, ]; /** * @var array{ * file: string|int|null, * dir: string|int|null, * } */ protected $writablePermissions = [ 'file' => '0664', 'dir' => '0775', ]; /** * @param array<string, mixed> $params */ public function __construct(private Manager $fileManager, array $params = null) { if ($params) { foreach ($params as $paramName => $paramValue) { switch ($paramName) { case 'defaultPermissions': /** @phpstan-ignore-next-line */ $this->defaultPermissions = array_merge($this->defaultPermissions, $paramValue); break; } } } } /** * Get default settings. * * @return array{ * dir: string|int|null, * file: string|int|null, * user: string|int|null, * group: string|int|null, * } */ public function getDefaultPermissions(): array { return $this->defaultPermissions; } /** * @return array<string, array<string, mixed>> */ public function getWritableMap(): array { return $this->writableMap; } /** * @return string[] */ public function getWritableList(): array { return array_keys($this->writableMap); } /** * @return array{ * dir: string|int|null, * file: string|int|null, * user: string|int|null, * group: string|int|null, * } */ public function getRequiredPermissions(string $path): array { $permission = $this->getDefaultPermissions(); foreach ($this->getWritableMap() as $writablePath => $writableOptions) { if (!$writableOptions['recursive'] && $path == $writablePath) { /** @phpstan-ignore-next-line */ return array_merge($permission, $this->writablePermissions); } if ($writableOptions['recursive'] && str_starts_with($path, $writablePath)) { /** @phpstan-ignore-next-line */ return array_merge($permission, $this->writablePermissions); } } return $permission; } /** * Set default permission. */ public function setDefaultPermissions(string $path, bool $recurse = false): bool { if (!file_exists($path)) { return false; } $permission = $this->getRequiredPermissions($path); $result = $this->chmod($path, [$permission['file'], $permission['dir']], $recurse); if (!empty($permission['user'])) { $result &= $this->chown($path, $permission['user'], $recurse); } if (!empty($permission['group'])) { $result &= $this->chgrp($path, $permission['group'], $recurse); } return (bool) $result; } /** * Get current permissions. * * @return string|false */ public function getCurrentPermission(string $filePath) { if (!file_exists($filePath)) { return false; } /** @var array{mode: mixed} $fileInfo */ $fileInfo = stat($filePath); return substr(base_convert((string) $fileInfo['mode'], 10, 8), -4); } /** * Change permissions. * * @param string $path * @param int|array<int|string, string|int|null>|string $octal Ex. `0755`, `[0644, 0755]`, `['file' => 0644, 'dir' => 0755]`. * @param bool $recurse */ public function chmod(string $path, $octal, bool $recurse = false): bool { if (!file_exists($path)) { return false; } /** @phpstan-var mixed $octal */ $permission = []; if (is_array($octal)) { $count = 0; $rule = ['file', 'dir']; foreach ($octal as $key => $val) { $pKey = strval($key); if (!in_array($pKey, $rule)) { $pKey = $rule[$count]; } if (!empty($pKey)) { $permission[$pKey]= $val; } $count++; } } else if (is_int((int) $octal)) { // Always true. @todo Fix. $permission = [ 'file' => $octal, 'dir' => $octal, ]; } // Convert to octal value. foreach ($permission as $key => $val) { if (is_string($val)) { $permission[$key] = base_convert($val, 8, 10); } } if (!$recurse) { if (is_dir($path)) { return $this->chmodReal($path, $permission['dir']); } return $this->chmodReal($path, $permission['file']); } return $this->chmodRecurse($path, $permission['file'], $permission['dir']); } /** * Change permissions recursive. * * @param int $fileOctal Ex. 0644. * @param int $dirOctal Ex. 0755. */ protected function chmodRecurse(string $path, $fileOctal = 0644, $dirOctal = 0755): bool { if (!file_exists($path)) { return false; } if (!is_dir($path)) { return $this->chmodReal($path, $fileOctal); } $result = $this->chmodReal($path, $dirOctal); /** @var string[] $allFiles */ $allFiles = $this->fileManager->getFileList($path); foreach ($allFiles as $item) { $result &= $this->chmodRecurse($path . Util::getSeparator() . $item, $fileOctal, $dirOctal); } return (bool) $result; } /** * Change owner permission. * * @param int|string $user */ public function chown(string $path, $user = '', bool $recurse = false): bool { if (!file_exists($path)) { return false; } if (empty($user)) { $user = $this->getDefaultOwner(); } if ($user === false) { // @todo Revise. $user = ''; } if (!$recurse) { return $this->chownReal($path, $user); } return $this->chownRecurse($path, $user); } /** * Change owner permission recursive. * * @param int|string $user */ protected function chownRecurse(string $path, $user): bool { if (!file_exists($path)) { return false; } if (!is_dir($path)) { return $this->chownReal($path, $user); } $result = $this->chownReal($path, $user); /** @var string[] $allFiles */ $allFiles = $this->fileManager->getFileList($path); foreach ($allFiles as $item) { $result &= $this->chownRecurse($path . Util::getSeparator() . $item, $user); } return (bool) $result; } /** * Change group permission. * * @param int|string $group * @noinspection SpellCheckingInspection */ public function chgrp(string $path, $group = null, bool $recurse = false): bool { if (!file_exists($path)) { return false; } if (!isset($group)) { $group = $this->getDefaultGroup(); } if ($group === false) { // @todo Revise. $group = ''; } if (!$recurse) { return $this->chgrpReal($path, $group); } return $this->chgrpRecurse($path, $group); } /** * Change group permission recursive. * * @param int|string $group * @noinspection SpellCheckingInspection */ protected function chgrpRecurse(string $path, $group): bool { if (!file_exists($path)) { return false; } if (!is_dir($path)) { return $this->chgrpReal($path, $group); } $result = $this->chgrpReal($path, $group); /** @var string[] $allFiles */ $allFiles = $this->fileManager->getFileList($path); foreach ($allFiles as $item) { $result &= $this->chgrpRecurse($path . Util::getSeparator() . $item, $group); } return (bool) $result; } /** * @param int $mode */ protected function chmodReal(string $filename, $mode): bool { $result = @chmod($filename, $mode); if ($result) { return true; } $defaultOwner = $this->getDefaultOwner(true); $defaultGroup = $this->getDefaultGroup(true); if ($defaultOwner === false) { // @todo Revise. $defaultOwner = ''; } if ($defaultGroup === false) { // @todo Revise. $defaultGroup = ''; } $this->chown($filename, $defaultOwner); $this->chgrp($filename, $defaultGroup); return @chmod($filename, $mode); } /** * @param int|string $user */ protected function chownReal(string $path, $user): bool { return @chown($path, $user); } /** * @param int|string $group * @noinspection SpellCheckingInspection * @todo Revise the need of exception handling. * */ protected function chgrpReal(string $path, $group): bool { return @chgrp($path, $group); } /** * Get default owner user. * * @return string|int|false owner id. */ public function getDefaultOwner(bool $usePosix = false) { $defaultPermissions = $this->getDefaultPermissions(); $owner = $defaultPermissions['user']; if (empty($owner) && $usePosix) { $owner = function_exists('posix_getuid') ? posix_getuid() : null; } if (empty($owner)) { return false; } return $owner; } /** * Get default group user. * * @return string|int|false Group id. */ public function getDefaultGroup(bool $usePosix = false) { $defaultPermissions = $this->getDefaultPermissions(); $group = $defaultPermissions['group']; if (empty($group) && $usePosix) { $group = function_exists('posix_getegid') ? posix_getegid() : null; } if (empty($group)) { return false; } return $group; } /** * Set permission regarding defined in permissionMap. */ public function setMapPermission(): bool { $this->permissionError = []; $this->permissionErrorRules = []; $result = true; foreach ($this->getWritableMap() as $path => $options) { if (!file_exists($path)) { continue; } try { $this->chmod($path, $this->writablePermissions, $options['recursive']); } catch (Throwable) {} /** check is writable */ $res = is_writable($path); if (is_dir($path)) { try { $name = uniqid(); $res &= $this->fileManager->putContents($path . '/' . $name, 'test'); $res &= $this->fileManager->removeFile($name, $path); } catch (Throwable) { $res = false; } } if (!$res) { $result = false; $this->permissionError[] = $path; $this->permissionErrorRules[$path] = $this->writablePermissions; } } return $result; } /** * Get last permission error. * * @return string[] */ public function getLastError() { return $this->permissionError; } /** * Get last permission error rules. * * @return ?array<string, array<string, string>> */ public function getLastErrorRules() { return $this->permissionErrorRules; } /** * Arrange permission file list. * * e.g. * ``` * [ * 'application/Espo/Controllers/Email.php', * 'application/Espo/Controllers/Import.php', * ] * ``` * result will be `['application/Espo/Controllers']`. * * @param string[] $fileList * @return string[] */ public function arrangePermissionList(array $fileList): array { $betterList = []; foreach ($fileList as $fileName) { $pathInfo = pathinfo($fileName); /** @var string $dirname */ $dirname = $pathInfo['dirname'] ?? null; $currentPath = $fileName; if ($this->getSearchCount($dirname, $fileList) > 1) { $currentPath = $dirname; } if (!$this->itemIncludes($currentPath, $betterList)) { $betterList[] = $currentPath; } } return $betterList; } /** * Get count of a search string in an array. * * @param string $search * @param string[] $array * @return int */ protected function getSearchCount(string $search, array $array) { $searchQuoted = $this->getPregQuote($search); $number = 0; foreach ($array as $value) { if (preg_match('/^' . $searchQuoted . '/', $value)) { $number++; } } return $number; } /** * @param string[] $array */ protected function itemIncludes(string $item, array $array): bool { foreach ($array as $value) { $value = $this->getPregQuote($value); if (preg_match('/^' . $value . '/', $item)) { return true; } } return false; } /** * @return string */ protected function getPregQuote(string $string): string { return preg_quote($string, '/-+=.'); } }