. * * 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\Upgrades\Actions; use Espo\Core\DataManager; use Espo\Core\Utils\Config; use Espo\Core\Utils\File\Manager as FileManager; use Espo\Core\Utils\Id\RecordIdGenerator; use Espo\Core\Utils\Util; use Espo\Core\Utils\System; use Espo\Core\Utils\Json; use Espo\Core\Exceptions\Error; use Espo\Core\Container; use Espo\Core\InjectableFactory; use Espo\Core\Upgrades\ActionManager; use Espo\Core\Utils\Config\ConfigWriter; use Espo\Core\Utils\Database\Helper as DatabaseHelper; use Espo\Core\Utils\File\ZipArchive; use Espo\Core\Utils\Log; use Composer\Semver\Semver; use Espo\ORM\EntityManager; use Throwable; abstract class Base { /** Directory name of files in a package. */ protected const FILES = 'files'; /** Directory name of scripts in a package. */ protected const SCRIPTS = 'scripts'; private string $defaultPackageType = 'extension'; private string $vendorDirName = 'vendor'; protected string $manifestName = 'manifest.json'; private string $packagePostfix = 'z'; /** @var array */ protected mixed $data = []; /** @var array */ private array $params; protected ?string $processId = null; protected ?string $parentProcessId = null; /** @var array */ protected array $scriptParams = []; /** @var array */ private array $packageTypes = [ 'upgrade' => 'upgrade', 'extension' => 'extension', ]; private ZipArchive $zipUtil; private ?DatabaseHelper $databaseHelper = null; private ?Helper $helper = null; public function __construct( private Container $container, private ActionManager $actionManager ) { $this->params = $actionManager->getParams(); $fileManager = $container->getByClass(FileManager::class); $this->zipUtil = new ZipArchive($fileManager); } private function getContainer(): Container { return $this->container; } private function getParam(string $name): mixed { if (isset($this->params[$name])) { return $this->params[$name]; } return null; } private function getZipUtil(): ZipArchive { return $this->zipUtil; } private function getDatabaseHelper(): DatabaseHelper { if (!isset($this->databaseHelper)) { /** @var InjectableFactory $injectableFactory */ $injectableFactory = $this->getContainer()->get('injectableFactory'); $this->databaseHelper = $injectableFactory->create(DatabaseHelper::class); } return $this->databaseHelper; } protected function getLog(): Log { return $this->getContainer()->getByClass(Log::class); } protected function getFileManager(): FileManager { return $this->getContainer()->getByClass(FileManager::class); } protected function getConfig(): Config { return $this->getContainer()->getByClass(Config::class); } protected function getSystemConfig(): Config\SystemConfig { return $this->getContainer()->getByClass(Config\SystemConfig::class); } protected function getEntityManager(): EntityManager { return $this->getContainer()->getByClass(EntityManager::class); } protected function getInjectableFactory(): InjectableFactory { return $this->getContainer()->getByClass(InjectableFactory::class); } protected function createConfigWriter(): ConfigWriter { $injectableFactory = $this->getContainer()->getByClass(InjectableFactory::class); return $injectableFactory->create(ConfigWriter::class); } /** * @throws Error */ public function throwErrorAndRemovePackage( string $errorMessage = '', bool $deletePackage = true, bool $systemRebuild = true, ?Throwable $exception = null ): void { if ($deletePackage) { $this->deletePackageFiles(); $this->deletePackageArchive(); } $this->disableMaintenanceMode(true); if ($systemRebuild) { $this->systemRebuild(); } if ($exception && !$errorMessage) { $errorMessage = $exception->getMessage(); } throw new Error($errorMessage, 0, $exception); } abstract public function run(mixed $data): mixed; /** * @throws Error */ protected function createProcessId(): string { if (isset($this->processId)) { throw new Error('Another installation process is currently running.'); } $recordIdGenerator = $this->getInjectableFactory()->createResolved(RecordIdGenerator::class); $this->processId = $recordIdGenerator->generate(); return $this->processId; } /** * @throws Error */ protected function getProcessId(): string { if (!isset($this->processId)) { throw new Error('Installation ID was not specified.'); } return $this->processId; } private function getParentProcessId(): ?string { return $this->parentProcessId; } public function setProcessId(string $processId): void { $this->processId = $processId; } protected function setParentProcessId(string $processId): void { $this->parentProcessId = $processId; } /** * Check if version of upgrade/extension is acceptable to current version of EspoCRM. * * @throws Error */ protected function isAcceptable(): bool { $manifest = $this->getManifest(); $res = $this->checkPackageType(); if (isset($manifest['php'])) { $res &= $this->checkVersions( $manifest['php'], System::getPhpVersion(), 'Your PHP version ({version}) is not supported. Required version: {requiredVersion}.' ); } if (isset($manifest['database'])) { $databaseHelper = $this->getDatabaseHelper(); $databaseType = $databaseHelper->getType(); $databaseTypeLc = strtolower($databaseType); if (isset($manifest['database'][$databaseTypeLc])) { $databaseVersion = $databaseHelper->getVersion(); if ($databaseVersion) { $res &= $this->checkVersions( $manifest['database'][$databaseTypeLc], $databaseVersion, 'Your '. $databaseType . ' version ({version}) is not supported. Required version: {requiredVersion}.' ); } } } $version = $this->getSystemConfig()->getVersion(); // Skip @@version for extension development. if (isset($manifest['acceptableVersions']) && $version !== '@@version') { $res &= $this->checkVersions( $manifest['acceptableVersions'], $version, 'Your EspoCRM version ({version}) is not supported. Required version: {requiredVersion}.' ); } if (!empty($manifest['dependencies'])) { $res &= $this->checkDependencies($manifest['dependencies']); } return (bool) $res; } /** * @param string[]|string $versionList * @throws Error */ public function checkVersions($versionList, ?string $currentVersion, string $errorMessage = ''): bool { if (empty($versionList)) { return true; } if (!$currentVersion) { return false; } if (is_string($versionList)) { $versionList = (array) $versionList; } $version = null; foreach ($versionList as $version) { $isInRange = false; try { $isInRange = Semver::satisfies($currentVersion, $version); } catch (Throwable $e) { $this->getLog()->error("SemVer: Version identification error: {$e->getMessage()}."); } if ($isInRange) { return true; } } /** @noinspection RegExpRedundantEscape */ $errorMessage = preg_replace('/\{version\}/', $currentVersion, $errorMessage); if (!is_string($errorMessage)) { $errorMessage = '?'; } /** @noinspection PhpArgumentWithoutNamedIdentifierInspection */ /** @noinspection RegExpRedundantEscape */ $errorMessage = preg_replace('/\{requiredVersion\}/', $version, $errorMessage); if (!is_string($errorMessage)) { $errorMessage = '?'; } $this->throwErrorAndRemovePackage($errorMessage); return false; } /** * @throws Error */ private function checkPackageType(): bool { $manifest = $this->getManifest(); /** check package type */ $type = strtolower($this->getParam('name')); $manifestType = isset($manifest['type']) ? strtolower($manifest['type']) : $this->defaultPackageType; if (!in_array($manifestType, $this->packageTypes)) { $this->throwErrorAndRemovePackage('Unknown package type.'); } if ($type != $manifestType) { $uType = ucfirst($type); $this->throwErrorAndRemovePackage( "Wrong package type. You cannot install $manifestType package via $uType Manager."); } return true; } /** * @param array $dependencyList */ protected function checkDependencies(array $dependencyList): bool { return true; } /** * Run a script by a type. * * @param string $type Ex. "before", "after". * @throws Error */ protected function runScript(string $type): void { $beforeInstallScript = $this->getScriptPath($type); if (!$beforeInstallScript) { return; } $scriptNames = $this->getParam('scriptNames'); $scriptName = $scriptNames[$type]; require_once($beforeInstallScript); $script = new $scriptName(); try { assert(method_exists($script, 'run')); $script->run($this->getContainer(), $this->scriptParams); } catch (Throwable $e) { $this->throwErrorAndRemovePackage(exception: $e); } } /** * @throws Error */ protected function getScriptPath(string $type): ?string { $packagePath = $this->getPackagePath(); $scriptNames = $this->getParam('scriptNames'); $scriptName = $scriptNames[$type]; if (!isset($scriptName)) { return null; } $beforeInstallScript = Util::concatPath([$packagePath, self::SCRIPTS, $scriptName]) . '.php'; if (file_exists($beforeInstallScript)) { return $beforeInstallScript; } return null; } /** * Get package path, * * @throws Error */ protected function getPath(string $name = 'packagePath', bool $isPackage = false): string { $postfix = $isPackage ? $this->packagePostfix : ''; $processId = $this->getProcessId(); $path = Util::concatPath($this->getParam($name), $processId); return $path . $postfix; } /** * @throws Error */ protected function getPackagePath(bool $isPackage = false): string { return $this->getPath('packagePath', $isPackage); } /** * @return string[] * @throws Error */ protected function getDeleteList(string $type = 'delete'): array { $manifest = $this->getManifest(); switch ($type) { case 'delete': case 'deleteBeforeCopy': if (isset($manifest[$type])) { return $manifest[$type]; } break; case 'vendor': return $this->getVendorFileList('delete'); } return []; } /** * Get a list of files defined in manifest. * * @return string[] * @throws Error */ private function getDeleteFileList(): array { if (!isset($this->data['deleteFileList'])) { $deleteFileList = []; /** @noinspection PhpRedundantOptionalArgumentInspection */ $deleteList = array_merge( $this->getDeleteList('delete'), $this->getDeleteList('deleteBeforeCopy'), $this->getDeleteList('vendor') ); foreach ($deleteList as $itemPath) { if (is_dir($itemPath)) { /** @var string[] $fileList */ $fileList = $this->getFileManager()->getFileList($itemPath, true, '', true, true); $fileList = $this->concatStringWithArray($itemPath, $fileList); $deleteFileList = array_merge($deleteFileList, $fileList); continue; } $deleteFileList[] = $itemPath; } $this->data['deleteFileList'] = $deleteFileList; } return $this->data['deleteFileList']; } /** * Delete files defined in a manifest. * * @throws Error */ protected function deleteFiles(string $type = 'delete', bool $withEmptyDirs = false): bool { $deleteList = $this->getDeleteList($type); if (!empty($deleteList)) { return $this->getFileManager()->remove($deleteList, null, $withEmptyDirs); } return true; } /** * @return string[] * @throws Error */ protected function getCopyFileList(): array { if (!isset($this->data['fileList'])) { $packagePath = $this->getPackagePath(); $this->data['fileList'] = $this->getFileList($packagePath); } return $this->data['fileList']; } /** * @return string[] * @throws Error */ protected function getRestoreFileList(): array { if (!isset($this->data['restoreFileList'])) { $backupPath = $this->getPath('backupPath'); $this->data['restoreFileList'] = $this->getFileList($backupPath, true); } return $this->data['restoreFileList']; } /** * Get file directories (files, beforeInstallFiles, afterInstallFiles). * * @return string[] */ protected function getFileDirs(?string $parentDirPath = null): array { $dirNames = $this->getParam('customDirNames'); $paths = [self::FILES, $dirNames['before'], $dirNames['after']]; if (isset($parentDirPath)) { foreach ($paths as &$path) { $path = Util::concatPath($parentDirPath, $path); } } return $paths; } /** * Get file list from directories: files, beforeUpgradeFiles, afterUpgradeFiles. * * @return string[] * @throws Error */ private function getFileList(string $dirPath, bool $skipVendorFileList = false): array { $fileList = []; $paths = $this->getFileDirs($dirPath); foreach ($paths as $filesPath) { if (file_exists($filesPath)) { /** @var string[] $files */ $files = $this->getFileManager()->getFileList($filesPath, true, '', true, true); /** @var string[] $fileList */ $fileList = array_merge($fileList, $files); } } if (!$skipVendorFileList) { /** @noinspection PhpRedundantOptionalArgumentInspection */ $vendorFileList = $this->getVendorFileList('copy'); if (!empty($vendorFileList)) { /** @var string[] $fileList */ $fileList = array_merge($fileList, $vendorFileList); } } return $fileList; } /** * @param ?string[] $fileList * @throws Error */ protected function copy( string $sourcePath, string $destPath, bool $recursively = false, ?array $fileList = null, bool $copyOnlyFiles = false ): bool { try { return $this->getFileManager()->copy($sourcePath, $destPath, $recursively, $fileList, $copyOnlyFiles); } catch (Throwable $e) { $this->throwErrorAndRemovePackage(exception: $e); } return false; } /** * Copy files from upgrade/extension package. * * @throws Error */ protected function copyFiles(?string $type = null, string $dest = ''): bool { $filesPath = $this->getCopyFilesPath($type); if ($filesPath) { switch ($type) { case 'vendor': $dest = $this->vendorDirName; break; } return $this->copy($filesPath, $dest, true); } return true; } /** * Get needed file list based on type. E.g. file list for "beforeCopy" action. * * @throws Error */ protected function getCopyFilesPath(?string $type = null): ?string { switch ($type) { case 'before': case 'after': $dirNames = $this->getParam('customDirNames'); $dirPath = $dirNames[$type]; break; case 'vendor': $dirNames = $this->getParam('customDirNames'); if (isset($dirNames['vendor'])) { $dirPath = $dirNames['vendor']; } break; default: $dirPath = self::FILES; break; } if (isset($dirPath)) { $packagePath = $this->getPackagePath(); $filesPath = Util::concatPath($packagePath, $dirPath); if (file_exists($filesPath)) { return $filesPath; } } return null; } /** * @return string[] * @throws Error */ private function getVendorFileList(string $type = 'copy'): array { $list = []; $packagePath = $this->getPackagePath(); $dirNames = $this->getParam('customDirNames'); if (!isset($dirNames['vendor'])) { return $list; } $filesPath = Util::concatPath($packagePath, $dirNames['vendor']); if (!file_exists($filesPath)) { return $list; } switch ($type) { case 'copy': /** @var string[] $list */ $list = $this->getFileManager()->getFileList($filesPath, true, '', true, true); break; case 'delete': /** @var string[] $list */ $list = $this->getFileManager()->getFileList($filesPath, false, '', null, true); break; } foreach ($list as &$path) { $path = Util::concatPath($this->vendorDirName, $path); } return $list; } /** * @return array * @throws Error */ public function getManifest(): array { if (!isset($this->data['manifest'])) { $packagePath = $this->getPackagePath(); $manifestPath = Util::concatPath($packagePath, $this->manifestName); if (!file_exists($manifestPath)) { $this->throwErrorAndRemovePackage("It's not an Installation package."); } $manifestJson = $this->getFileManager()->getContents($manifestPath); $this->data['manifest'] = Json::decode($manifestJson, true); if (!$this->data['manifest']) { $this->throwErrorAndRemovePackage('Syntax error in manifest.json.'); } if (!$this->checkManifest($this->data['manifest'])) { $this->throwErrorAndRemovePackage('Unsupported package.'); } } return $this->data['manifest']; } /** * @param array $manifest */ private function checkManifest(array $manifest): bool { $requiredFields = [ 'name', 'version', ]; foreach ($requiredFields as $fieldName) { if (empty($manifest[$fieldName])) { return false; } } return true; } /** * @throws Error */ protected function getManifestParam(string $name): mixed { $manifest = $this->getManifest(); if (array_key_exists($name, $manifest)) { return $manifest[$name]; } return null; } /** * Unzip a package archive. * * @throws Error */ protected function unzipArchive(?string $packagePath = null): void { $packagePath = $packagePath ?? $this->getPackagePath(); $packageArchivePath = $this->getPackagePath(true); if (!file_exists($packageArchivePath)) { $this->throwErrorAndRemovePackage('Package Archive does not exist.', false, false); } $res = $this->getZipUtil()->unzip($packageArchivePath, $packagePath); if ($res === false) { $this->throwErrorAndRemovePackage("Unable to unzip the file $packagePath.", false, false); } } /** * Delete temporary package files. * * @throws Error */ protected function deletePackageFiles(): bool { $packagePath = $this->getPackagePath(); return $this->getFileManager()->removeInDir($packagePath, true); } /** * Delete temporary package archive. * * @throws Error */ protected function deletePackageArchive(): bool { $packageArchive = $this->getPackagePath(true); return $this->getFileManager()->removeFile($packageArchive); } protected function systemRebuild(): bool { try { $dataManager = $this->getContainer()->getByClass(DataManager::class); $dataManager->rebuild(); $dataManager->updateAppTimestamp(); return true; } catch (Throwable $e) { try { $this->getLog()->error("Database rebuild failure, details: {$e->getMessage()}.", ['exception' => $e]); } catch (Throwable) {} } return false; } /** * Execute an action. For ex., execute uninstall action in install. * * @param string|array $data * @throws Error */ protected function executeAction(string $actionName, $data): void { $actionManager = $this->actionManager; $currentAction = $actionManager->getAction(); $actionManager->setAction($actionName); $actionManager->run($data); $actionManager->setAction($currentAction); } protected function initialize(): void {} protected function finalize(): void {} protected function beforeRunAction(): void {} protected function afterRunAction(): void {} /** * @throws Error */ protected function clearCache(): void { $dataManager = $this->getContainer()->getByClass(DataManager::class); $dataManager->clearCache(); } /** * @throws Error */ protected function checkIsWritable(): void { $backupPath = $this->getPath('backupPath'); $fullFileList = array_merge([$backupPath], $this->getDeleteFileList(), $this->getCopyFileList()); $result = $this->getFileManager()->isWritableList($fullFileList); if ($result) { return; } $permissionDeniedList = $this->getFileManager()->getLastPermissionDeniedList(); $delimiter = $this->isCli() ? "\n" : "
"; $this->throwErrorAndRemovePackage( "Permission denied: " . $delimiter . implode($delimiter, $permissionDeniedList), false, false ); } /** * @throws Error */ protected function backupExistingFiles(): bool { $fullFileList = array_merge($this->getDeleteFileList(), $this->getCopyFileList()); $backupPath = $this->getPath('backupPath'); $destination = Util::concatPath($backupPath, self::FILES); return $this->copy('', $destination, false, $fullFileList); } protected function getHelper(): Helper { if (!$this->helper) { $this->helper = new Helper($this->getEntityManager()); } $this->helper->setActionObject($this); assert($this->helper !== null); return $this->helper; } /** * @param string $string * @param string[] $array * @return string[] */ private function concatStringWithArray(string $string, array $array): array { foreach ($array as &$value) { if (!str_ends_with($string, '/')) { $string .= '/'; } $value = $string . $value; } return $array; } /** * @throws Error */ protected function enableMaintenanceMode(): void { $config = $this->getConfig(); $configWriter = $this->createConfigWriter(); $configParamName = $this->getTemporaryConfigParamName(); $parentConfigParamName = $this->getTemporaryConfigParamName(true); if ( ($configParamName && $config->has($configParamName)) || ($parentConfigParamName && $config->has($parentConfigParamName)) ) { return; } $actualParams = [ 'maintenanceMode' => $config->get('maintenanceMode'), 'cronDisabled' => $config->get('cronDisabled'), 'useCache' => $config->get('useCache'), ]; if ($configParamName) { // @todo Maybe to remove this line? $configWriter->set($configParamName, $actualParams); } $save = false; if (!$actualParams['maintenanceMode']) { $configWriter->set('maintenanceMode', true); $save = true; } if (!$actualParams['cronDisabled']) { $configWriter->set('cronDisabled', true); $save = true; } if ($actualParams['useCache']) { $configWriter->set('useCache', false); $save = true; } if ($save) { $configWriter->save(); } } /** * @throws Error */ protected function disableMaintenanceMode(bool $force = false): void { $config = $this->getConfig(); $configWriter = $this->createConfigWriter(); $configParamList = [ $this->getTemporaryConfigParamName(), ]; if ($force && $this->getTemporaryConfigParamName(true)) { $configParamList[] = $this->getTemporaryConfigParamName(true); } $save = false; foreach ($configParamList as $configParamName) { if ($configParamName === null) { continue; } if (!$config->has($configParamName)) { continue; } foreach ($config->get($configParamName, []) as $paramName => $paramValue) { if ($config->get($paramName) != $paramValue) { $configWriter->set($paramName, $paramValue); } } $configWriter->remove($configParamName); $save = true; } if ($save) { $configWriter->save(); } } /** * @throws Error */ private function getTemporaryConfigParamName(bool $isParentProcess = false): ?string { $processId = $this->getProcessId(); if ($isParentProcess) { $processId = $this->getParentProcessId(); if (!$processId) { return null; } } return 'temporaryUpgradeParams' . $processId; } private function isCli(): bool { if (str_starts_with(php_sapi_name() ?: '', 'cli')) { return true; } return false; } }