. * * 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\Tools\ExportCustom; use GuzzleHttp\Psr7\Utils; use Espo\Core\FileStorage\Manager as FileStorageManager; use Espo\Core\Utils\DateTime; use Espo\Core\Utils\File\Manager as FileManager; use Espo\Core\Utils\Json; use Espo\Core\Utils\Metadata; use Espo\Core\Utils\Util; use Espo\Entities\Attachment; use Espo\ORM\EntityManager; use Espo\Tools\EntityManager\NameUtil; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; use RuntimeException; use ZipArchive; class ExportCustom { private string $minVersion = '9.1.0'; /** @var string[] */ private array $metadataFolderList = [ 'scopes', 'entityDefs', 'clientDefs', 'recordDefs', 'selectDefs', 'aclDefs', 'entityAcl', 'logicDefs', 'formula', ]; public function __construct( private Metadata $metadata, private FileManager $fileManager, private NameUtil $nameUtil, private FileStorageManager $fileStorageManager, private EntityManager $entityManager ) {} public function process(Params $params): Result { $this->validate($params); $data = $this->createData($params); $this->createDir($data); $this->copy($data); $this->fixMetadata($params, $data); $this->filterLayouts($data); $this->createControllers($params, $data); $this->createModuleJson($data); $this->createManifest($params, $data); $attachmentId = $this->createAttachment($data); $this->cleanup($data); return new Result($attachmentId); } private function cleanup(Data $data): void { $this->fileManager->removeInDir($data->getDir(), true); $this->fileManager->removeFile($data->getDir() . '.zip'); } private function createDir(Data $data): void { $this->fileManager->mkdir($data->getDestDir(), 0755); } private function copy(Data $data): void { $customDir = 'custom/Espo/Custom'; $this->fileManager->copy( $customDir . '/Resources', $data->getDestDir() . '/Resources', true ); } private function fixMetadata(Params $params, Data $data): void { foreach ($data->customEntityTypeList as $scope) { $file = $data->getDestDir() . '/Resources/metadata/scopes/' . $scope . '.json'; if (!$this->fileManager->exists($file)) { throw new RuntimeException("File $file does not exist."); } $defs = Json::decode($this->fileManager->getContents($file)); unset($defs->isCustom); $defs->module = $params->getModule(); $defs->isCustomExported = true; $this->fileManager->putJsonContents($file, $defs); } $dir = $data->getDestDir() . '/Resources/metadata/entityDefs'; /** @var string[] $files */ $files = $this->fileManager->getFileList($dir); $files = array_filter($files, fn ($file) => str_ends_with($file, '.json')); foreach ($files as $file) { $fullFile = $dir . '/' . $file; $defs = Json::decode($this->fileManager->getContents($fullFile)); foreach (get_object_vars($defs->fields ?? (object) []) as $key => $value) { unset($defs->fields->$key->isCustom); } foreach (get_object_vars($defs->links ?? (object) []) as $key => $value) { unset($defs->links->$key->isCustom); } $this->fileManager->putJsonContents($fullFile, $defs); } $metadataDir = $data->getDestDir() . '/Resources/metadata'; /** @var string[] $list */ $list = $this->fileManager->getFileList($metadataDir); foreach ($list as $item) { if (in_array($item, $this->metadataFolderList)) { continue; } $file = $metadataDir . '/' . $item; if ($this->fileManager->isFile($file)) { $this->fileManager->removeFile($file); continue; } $this->fileManager->removeInDir($file, true); } } private function filterLayouts(Data $data): void { $dir = $data->getDestDir() . '/Resources/layouts'; /** @var string[] $list */ $list = $this->fileManager->getFileList($dir); foreach ($list as $item) { if (in_array($item, $data->customEntityTypeList)) { continue; } $file = $dir . '/' . $item; if ($this->fileManager->isFile($file)) { $this->fileManager->removeFile($file); continue; } $this->fileManager->removeInDir($file, true); } } private function createControllers(Params $params, Data $data): void { foreach ($data->customEntityTypeList as $scope) { $this->createController($params, $data, $scope); } } private function createModuleJson(Data $data): void { $file = $data->getDestDir() . '/Resources/module.json'; $defs = (object) ['order' => 30]; $this->fileManager->putJsonContents($file, $defs); } private function createController(Params $params, Data $data, string $scope): void { $shortClassName = Util::normalizeClassName($scope); $module = $params->getModule(); $contents = "<" . "?" . "php\n\n". "namespace Espo\\Modules\\$module\\Controllers;\n\n". "class $shortClassName extends \\Espo\\Core\\Templates\\Controllers\\Base\n". "{}\n"; $file = "{$data->getDestDir()}/Controllers/$shortClassName.php"; $this->fileManager->putContents($file, $contents); } private function createManifest(Params $params, Data $data): void { $file = $data->getDir() . '/manifest.json'; $defs = (object) [ 'name' => $params->getName(), 'version' => $params->getVersion(), 'author' => $params->getAuthor(), 'skipBackup' => true, 'releaseDate' => DateTime::getSystemTodayString(), 'description' => $params->getDescription(), 'acceptableVersions' => ['>=' . $this->minVersion] ]; $this->fileManager->putJsonContents($file, $defs); } private function createAttachment(Data $data): string { $zipFile = $this->createZip($data); $attachment = $this->entityManager->getRDBRepositoryByClass(Attachment::class)->getNew(); $attachment ->setName($data->folder . '.zip') ->setRole(Attachment::ROLE_EXPORT_FILE) ->setType('application/zip') ->setSize($this->fileManager->getSize($zipFile)); $this->entityManager->saveEntity($attachment); $resource = fopen($zipFile, 'r'); if ($resource === false) { throw new RuntimeException("Could not open file $zipFile."); } $this->fileStorageManager->putStream($attachment, Utils::streamFor($resource)); return $attachment->getId(); } private function createZip(Data $data): string { $zip = new ZipArchive(); $archiveFile = $data->getDir() . '.zip'; $openResult = $zip->open($archiveFile, ZipArchive::CREATE); if ($openResult !== true) { throw new RuntimeException("Could not open zip."); } $files = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($data->getDir() . '/'), RecursiveIteratorIterator::SELF_FIRST ); foreach ($files as $file => $fileDescriptor) { $file = str_replace("\\", "/", $file); if (!$this->fileManager->isFile($file)) { continue; } $entry = substr($file, strlen('data/tmp/' . $data->folder . '/')); $zip->addFile( getcwd() . '/' . $file, $entry ); } $zip->close(); return $archiveFile; } private function createData(Params $params): Data { $folder = Util::camelCaseToHyphen($params->getModule()) . '-' . $params->getVersion(); return new Data( folder: $folder, customEntityTypeList: $this->obtainCustomEntityTypeList(), module: $params->getModule(), ); } private function validate(Params $params): void { if (!preg_match('/^[A-Za-z][A-Za-z0-9 ]+$/', $params->getName())) { throw new RuntimeException("Bad extension name."); } if (!preg_match('/^[A-Z][a-z][A-Za-z]+$/', $params->getModule())) { throw new RuntimeException("Bad module name. Should be in CamelCase."); } if (!preg_match('/^[0-9]+\.[0-9]+\.[0-9]+$/', $params->getVersion())) { throw new RuntimeException("Bad version number."); } if ($this->nameUtil->nameIsNotAllowed($params->getModule())) { throw new RuntimeException("Not allowed module name."); } $forbiddenModuleNames = [ 'Advanced', 'AdvancedPack', 'Sales', 'SalesPack', 'Google', 'Outlook', 'Voip', ]; if (in_array($params->getModule(), $forbiddenModuleNames)) { throw new RuntimeException("Not allowed module name."); } } /** * @return string[] */ private function obtainCustomEntityTypeList(): array { $list = []; /** @var array> $scopes */ $scopes = $this->metadata->get('scopes', []); foreach ($scopes as $scope => $item) { $isCustom = $item['isCustom'] ?? null; $isEntity = $item['entity'] ?? null; if (!$isCustom || !$isEntity) { continue; } $list[] = $scope; } return $list; } }