Files
EasySoft-ZenTaoPMS/module/extension/model.php
T
liugang 7eaf0e84a7 * [refactor] Refactor SQL processing for database installation and upgrade
- Extract SQL processing methods to install model for better reusability
- Add replaceContantsInSQL() and appendMySQLTableOptions() public methods
- Unify SQL constants replacement across installation and upgrade modules
- Remove duplicate SQL processing code in extension and upgrade models
- Fix __TABLE__ to __DATABASE__ in zentao.sql for consistency
- Improve code maintainability and reduce duplication
2025-11-24 16:17:02 +08:00

764 lines
25 KiB
PHP

<?php
declare(strict_types=1);
/**
* The model file of extension module of ZenTaoPMS.
*
* @copyright Copyright 2009-2023 禅道软件(青岛)有限公司(ZenTao Software (Qingdao) Co., Ltd. www.cnezsoft.com)
* @license ZPL(http://zpl.pub/page/zplv12.html) or AGPL(https://www.gnu.org/licenses/agpl-3.0.en.html)
* @author Chunsheng Wang <chunsheng@cnezsoft.com>
* @package extension
* @version $Id$
* @link https://www.zentao.net
*/
class extensionModel extends model
{
/**
* The extension manager version. Don't change it.
*/
const EXT_MANAGER_VERSION = '1.3';
/**
* The api root.
*
* @var string
* @access public
*/
public $apiRoot;
/**
* The package root.
*
* @var string
* @access public
*/
public $pkgRoot;
/**
* 构造函数。
* The construct function.
*
* @access public
* @return void
*/
public function __construct()
{
parent::__construct();
$this->apiRoot = $this->config->extension->apiRoot;
$this->classFile = $this->app->loadClass('zfile');
$this->pkgRoot = $this->app->getExtensionRoot() . 'pkg' . DS;
}
/**
* 根据状态获取本地安装的插件。
* Get extensions by status.
*
* @param string $status
* @access public
* @return array
*/
public function getLocalExtensions(string $status): array
{
$extensions = $this->dao->select('*')->from(TABLE_EXTENSION)->where('status')->in($status)->fi()->fetchAll('code', false);
foreach($extensions as $extension)
{
if($extension->site && stripos(strtolower($extension->site), 'http') === false) $extension->site = 'http://' . $extension->site;
}
return $extensions;
}
/**
* 根据插件代号从数据库获取插件信息。
* Get extension info from database.
*
* @param string $extension
* @access public
* @return object
*/
public function getInfoFromDB(string $extension): object|false
{
return $this->dao->select('*')->from(TABLE_EXTENSION)->where('code')->eq($extension)->fetch();
}
/**
* 获取可能依赖此插件的其他插件。
* Get other extensions that may depend on this extension.
*
* @param string $extension
* @access public
* @return array
*/
public function getDependsExtension(string $extension): array
{
return $this->dao->select('*')->from(TABLE_EXTENSION)->where('depends')->like("%$extension%")->andWhere('status')->ne('available')->fetchAll();
}
/**
* 根据插件代号从插件包获取插件信息。
* Get info of an extension from the package file.
*
* @param string $extension
* @access public
* @return object
*/
public function getInfoFromPackage(string $extension): object
{
/* Init the data. */
$data = new stdclass();
$data->name = $extension;
$data->code = $extension;
$data->version = 'unknown';
$data->author = 'unknown';
$data->desc = $extension;
$data->site = 'unknown';
$data->license = 'unknown';
$data->zentaoCompatible = '';
$data->type = '';
$data->depends = '';
$info = $this->parseExtensionCFG($extension);
foreach($info as $key => $value)
{
if(isset($data->$key)) $data->$key = is_null($value) ? '' : $value;
}
if(isset($info->zentaoversion)) $data->zentaoCompatible = $info->zentaoversion;
if(isset($info->zentao['compatible'])) $data->zentaoCompatible = $info->zentao['compatible'];
if(isset($info->depends)) $data->depends = json_encode($info->depends);
return $data;
}
/**
* 根据插件代号获取插件包的兼容性信息。
* Get the extension's condition.
*
* @param string $extenstion
* @access public
* @return object
*/
public function getCondition(string $extension): object
{
$info = $this->parseExtensionCFG($extension);
$condition = new stdclass();
$condition->zentao = array('compatible' => '', 'incompatible' => '');
$condition->depends = '';
$condition->conflicts = '';
if(isset($info->zentao)) $condition->zentao = $info->zentao;
if(isset($info->depends)) $condition->depends = $info->depends;
if(isset($info->conflicts)) $condition->conflicts = $info->conflicts;
/* zentaoversion和zentaoVersion哪个有值取那个。 */
if(isset($info->zentaoVersion)) $condition->zentao['compatible'] = $info->zentaoVersion;
if(isset($info->zentaoversion)) $condition->zentao['compatible'] = $info->zentaoversion;
return $condition;
}
/**
* 调用禅道官网接口获取插件的分类。
* Get extension modules from the api.
*
* @access public
* @return array|bool
*/
public function getModulesByAPI(): array|bool
{
$requestType = helper::safe64Encode($this->config->requestType);
$webRoot = helper::safe64Encode($this->config->webRoot, '', false, true);
$apiURL = "{$this->apiRoot}apiGetmodules-{$requestType}-{$webRoot}.json";
$data = $this->fetchAPI($apiURL);
if(isset($data->newmodules)) return $data->newmodules;
return false;
}
/**
* 调用禅道官网接口获取插件的版本。
* Get versions for some extensions.
*
* @param string $extensions
* @access public
* @return array|bool
*/
public function getVersionsByAPI(string $extensions): array|bool
{
$extensions = helper::safe64Encode($extensions);
$apiURL = "{$this->apiRoot}apiGetVersions-{$extensions}.json";
$data = $this->fetchAPI($apiURL);
if(isset($data->versions)) return (array)$data->versions;
return false;
}
/**
* 调用禅道官网接口获取插件的列表。
* Get extensions by some condition.
*
* @param string $type
* @param string $param
* @param int $recTotal
* @param int $recPerPage
* @param int $pageID
* @access public
* @return object|bool
*/
public function getExtensionsByAPI(string $type, string $param, int $recTotal = 0, int $recPerPage = 20, int $pageID = 1): object|false
{
$apiURL = $this->apiRoot . "apiGetExtensions-$type-$param-$recTotal-$recPerPage-$pageID.json";
$data = $this->fetchAPI($apiURL);
if(isset($data->extensions))
{
foreach($data->extensions as $extension)
{
$extension->currentRelease = isset($extension->compatibleRelease) ? $extension->compatibleRelease : $extension->latestRelease;
$extension->currentRelease->compatible = isset($extension->compatibleRelease);
}
return $data;
}
return false;
}
/**
* 根据插件代号获取插件包里的文件夹列表。
* Get paths from an extension package.
*
* @param string $extension
* @access public
* @return array
*/
public function getPathsFromPackage(string $extension): array
{
$paths = array();
$packageFile = $this->getPackageFile($extension);
/* Get files from the package file. */
$this->app->loadClass('pclzip', true);
$zip = new pclzip($packageFile);
$files = $zip->listContent();
if($files)
{
foreach($files as $file)
{
$file = (object)$file;
if($file->folder) continue;
$file->filename = substr($file->filename, strpos($file->filename, '/') + 1);
$paths[] = dirname($file->filename);
}
}
return array_unique($paths);
}
/**
* 根据插件代号获取插件包里除db和doc目录外的文件列表。
* Get all files from a package.
*
* @param string $extension
* @access public
* @return array
*/
public function getFilesFromPackage(string $extension): array
{
$extensionDir = $this->pkgRoot . $extension;
return $this->classFile->readDir($extensionDir, array('db', 'doc'));
}
/**
* 根据插件代号获取上传的插件包路径。
* Get the full path of the zip file of a extension.
*
* @param string $extension
* @access public
* @return string
*/
public function getPackageFile(string $extension): string
{
return $this->app->getTmpRoot() . 'extension/' . $extension . '.zip';
}
/**
* 根据插件代号获取数据库执行文件。
* Get the install db file.
*
* @param string $extension
* @param string $method install|upgrade
* @access public
* @return string
*/
public function getDBFile(string $extension, string $method = 'install'): string
{
return $this->pkgRoot . "$extension/db/$method.sql";
}
/**
* 获取插件到期时间。
* Get extension expire date.
*
* @param object $extension
* @access public
* @return string
*/
public function getExpireDate(object $extension): string
{
$licencePath = $this->app->getConfigRoot() . 'license/';
$today = date('Y-m-d');
$expiredDate = '';
$licenceOrderFiles = glob($licencePath . 'order*.txt');
foreach($licenceOrderFiles as $licenceOrderFile)
{
/* 找到和当前插件代号匹配的授权文件。 */
if(stripos($licenceOrderFile, "{$extension->code}{$extension->version}.txt") === false) continue;
$order = file_get_contents($licenceOrderFile);
$order = unserialize($order);
/* 如果是终生版授权,直接返回life。 */
if($order->type == 'life') return 'life';
/* 获取授权总时长。 */
$days = 0;
if($order->type == 'demo') $days = 31; // 试用授权时长31天。
if($order->type == 'year') $days = 365; // 一年版授权时长365天。
if(!$days && isset($order->days)) $days = $order->days; // 自定义授权时长根据days字段设置。
/* 根据授权时长和购买插件时间获取到期时间。 */
$startDate = !helper::isZeroDate($order->paidDate) ? $order->paidDate : $order->createdDate;
if($days) $expiredDate = date('Y-m-d', strtotime($startDate) + $days * 24 * 3600); // 如果授权时长是0则代表是不限时长的授权。
}
return $expiredDate;
}
/**
* 获取即将到期和已过期的插件列表。
* Get plugins that are about to expire or have expired.
*
* @param bool $isGroup 是否分组
* @access public
* @return array
*/
public function getExpiringPlugins(bool $isGroup = false): array
{
$extensions = $this->getLocalExtensions('installed');
$plugins = $isGroup ? array('expiring' => array(), 'expired' => array()) : array();
$today = helper::today();
foreach($extensions as $extension)
{
$expiredDate = $this->getExpireDate($extension);
if(!empty($expiredDate) && $expiredDate != 'life')
{
$dateDiff = helper::diffDate($expiredDate, $today);
if($isGroup)
{
/* 分组获取即将过期和已过期的插件列表。 */
if($dateDiff == 30 || $dateDiff == 14 || ($dateDiff <= 7 && $dateDiff >= 0)) $plugins['expiring'][] = $extension->name;
if($dateDiff <= -1) $plugins['expired'][] = $extension->name;
}
else
{
/* 获取即将过期和已过期的插件列表。 */
if($dateDiff == 30 || $dateDiff == 14 || $dateDiff <= 7) $plugins[] = $extension->name;
}
}
}
return $plugins;
}
/**
* 根据插件的当前版本号返回插件的当前兼容版本。
* Check incompatible extension
*
* @param array $versions
* @access public
* @return array
*/
public function checkIncompatible(array $versions): array
{
$apiURL = $this->apiRoot . 'apiCheckIncompatible' . '.json?versions=' . helper::safe64Encode(json_encode($versions));
$data = $this->fetchAPI($apiURL);
if(isset($data->incompatibleExts)) return (array)$data->incompatibleExts;
return array();
}
/**
* 检查当前禅道版本是否包含在指定版本号中。
* Check the extension's version is compatibility for zentao version
*
* @param string $version
* @access public
* @return bool
*/
public function checkVersion(string $version): bool
{
if($version == 'all') return true;
$version = explode(',', $version);
if(in_array($this->config->version, $version)) return true;
return false;
}
/**
* 根据插件代号从插件包获取配置信息。
* Parse extension's config file.
*
* @param string $extension
* @access public
* @return object
*/
public function parseExtensionCFG(string $extension): object
{
/* First, try ini file. before 2.5 version. */
$infoFile = $this->pkgRoot . "$extension/doc/copyright.txt";
if(file_exists($infoFile)) return (object)parse_ini_file($infoFile);
/**
* Then try parse yaml file. since 2.5 version.
*/
/* Try the yaml of current lang, then try en. */
$info = new stdclass();
$lang = $this->app->getClientLang();
$infoFile = $this->pkgRoot . "$extension/doc/$lang.yaml";
if(!file_exists($infoFile)) $infoFile = $this->pkgRoot . "$extension/doc/en.yaml";
if(!file_exists($infoFile)) return $info;
/* Load the yaml file and parse it into object. */
$spyc = $this->app->loadClass('spyc');
$info = (object)$spyc->loadFile($infoFile);
if(isset($info->releases))
{
$info->version = key($info->releases);
foreach(array_keys($info->releases) as $version)
{
if(version_compare($info->version, $version, '<')) $info->version = $version;
}
foreach($info->releases[$info->version] as $key => $value) $info->$key = $value;
}
return $info;
}
/**
* 生成授权协议内容。
* Process license. If is opensource return the full text of it.
*
* @param string $license apache|bsd|gpl|lgpl|mit|自定义内容
* @access public
* @return string
*/
public function processLicense(string $license): string
{
if(strlen($license) > 10) return $license; // 大于10个字符的就代表是自定义协议内容,直接返回即可.
$licenseFile = dirname(__FILE__) . '/license/' . strtolower($license) . '.txt';
if(file_exists($licenseFile)) return file_get_contents($licenseFile);
return $license;
}
/**
* 删除安装的插件文件并返回错误提示。
* Remove an extension.
*
* @param string $extension
* @access public
* @return array
*/
public function removePackage(string $extension): array
{
$extension = $this->getInfoFromDB($extension);
if(empty($extension)) return array();
if($extension->type == 'patch') return array(); // 无法删除补丁类型的插件包。
$removeFilesTips = $this->removeExtensionFiles($extension->files);
$removeDirsTips = $this->removeExtensionDirs($extension->dirs);
/* Clean model cache files. */
$this->cleanModelCache();
return array_merge($removeFilesTips, $removeDirsTips);
}
/**
* 清除插件并删除文件。
* Erase an extension's package file.
*
* @param string $extension
* @access public
* @return array
*/
public function erasePackage(string $extension): array
{
$this->dao->delete()->from(TABLE_EXTENSION)->where('code')->eq($extension)->exec();
$packageFile = $this->getPackageFile($extension);
if(!file_exists($packageFile)) return array();
/* Remove the zip file. */
$removeCommands = array();
if(file_exists($packageFile) && !@unlink($packageFile))
{
$removeCommands[] = PHP_OS == 'Linux' ? "rm -fr $packageFile" : "del $packageFile";
}
/* Remove the extracted files. */
$extractedDir = $this->pkgRoot . $extension;
if($extractedDir && $extractedDir != '/' && !$this->classFile->removeDir($extractedDir))
{
$removeCommands[] = PHP_OS == 'Linux' ? "rm -fr $extractedDir" : "rmdir $extractedDir /s";
}
return $removeCommands;
}
/**
* 执行插件包中安装或者卸载时的SQL语句。
* Execute the extension db.
*
* @param string $extension
* @param string $method install|uninstall
* @access public
* @return object
*/
public function executeDB(string $extension, string $method = 'install'): object
{
$result = new stdclass();
$result->result = 'ok';
$result->error = '';
/* 获取安装或者卸载对应的SQL文件。 */
$dbFile = $this->getDBFile($extension, $method);
if(!file_exists($dbFile)) return $result;
/* 获取文件中的SQL语句。 */
$sqls = file_get_contents($this->getDBFile($extension, $method));
$sqls = explode(';', $sqls);
$this->loadModel('install');
$ignoreCode = '|1050|1060|1062|1091|1169|';
foreach($sqls as $sql)
{
$sql = trim($sql);
if(empty($sql)) continue;
$sql = $this->install->replaceContantsInSQL($sql);
$sql = $this->install->appendMySQLTableOptions($sql);
try
{
$this->dbh->query($sql);
$this->dao->setTableCache($sql);
}
catch(PDOException $e)
{
$errorInfo = $e->errorInfo;
$errorCode = $errorInfo ? $errorInfo[1] : '';
if(strpos($ignoreCode, "|$errorCode|") === false) $result->error .= '<p>' . $e->getMessage() . "<br />THE SQL IS: $sql</p>";
}
}
if($result->error) $result->result = 'fail';
return $result;
}
/**
* 保存插件信息到数据库。
* Save the extension to database.
*
* @param string $code
* @param string $type
* @access public
* @return bool
*/
public function saveExtension(string $code, string $type): bool
{
//从插件包中获取配置信息。
$extension = $this->getInfoFromPackage($code);
$extension->status = 'available'; // 安装时的默认状态是已下载,后续安装完成后会变更。
$extension->code = $code;
$extension->type = empty($type) ? $extension->type : $type;
$extension->installedTime = helper::now();
$this->dao->replace(TABLE_EXTENSION)->data($extension)->exec();
return !dao::isError();
}
/**
* 更新插件信息到数据库。
* Update an extension.
*
* @param array $extension
* @access public
* @return bool
*/
public function updateExtension(array $extension): bool
{
if(empty($extension['code'])) return false;
/* 安装插件时新增的文件夹目录。 */
$appRoot = $this->app->getAppRoot();
if(isset($extension['dirs']))
{
if($extension['dirs'])
{
foreach($extension['dirs'] as $key => $dir) $extension['dirs'][$key] = str_replace($appRoot, '', $dir);
}
$extension['dirs'] = json_encode($extension['dirs']);
}
/* 安装插件时复制的文件目录。 */
if(isset($extension['files']))
{
foreach($extension['files'] as $fullFilePath => $md5)
{
$relativeFilePath = str_replace($appRoot, '', $fullFilePath);
$extension['files'][$relativeFilePath] = $md5;
unset($extension['files'][$fullFilePath]);
}
$extension['files'] = json_encode($extension['files']);
}
$this->dao->update(TABLE_EXTENSION)->data($extension)->where('code')->eq($extension['code'])->exec();
return !dao::isError();
}
/**
* 调用接口并返回结果中的data。
* Fetch data from an api.
*
* @param string $url
* @access private
* @return mixed
*/
private function fetchAPI(string $url)
{
/* 拼接URL并调用接口。 */
$version = $this->loadModel('upgrade')->getOpenVersion(str_replace('.', '_', $this->config->version));
$version = str_replace('_', '.', $version);
$url .= (strpos($url, '?') === false ? '?' : '&') . 'lang=' . str_replace('-', '_', $this->app->getClientLang()) . '&managerVersion=' . self::EXT_MANAGER_VERSION;
$url .= '&zentaoVersion=' . $version . '&edition=' . $this->config->edition;
$result = json_decode(common::http($url));
/* 返回接口结果。 */
if(!isset($result->status)) return false;
if($result->status != 'success') return false;
if(isset($result->data) && md5($result->data) != $result->md5) return false;
if(isset($result->data)) return json_decode($result->data);
return false;
}
/**
* 删除安装插件时拷贝到禅道目录的插件文件。
* Remove extension files.
*
* @param string $files
* @access private
* @return array
*/
private function removeExtensionFiles(string $files): array
{
$commandTips = array();
$appRoot = $this->app->getAppRoot();
$files = json_decode($files);
if($files)
{
foreach($files as $file => $savedMD5)
{
$file = $appRoot . $file;
if(!file_exists($file)) continue;
/* 如果没有权限或者删除失败则返回提示信息。 */
$parentDir = dirname($file);
if(!is_writable($file) || !is_writable($parentDir))
{
$commandTips[] = PHP_OS == 'Linux' ? "sudo rm -fr $file" : "del $file";
}
elseif(@md5_file($file) != $savedMD5)
{
$commandTips[] = PHP_OS == 'Linux' ? "sudo rm -fr $file" : "del $file";
}
elseif(!@unlink($file))
{
$commandTips[] = PHP_OS == 'Linux' ? "sudo rm -fr $file" : "del $file";
}
}
}
return $commandTips;
}
/**
* 删除安装插件时在禅道目录新增的文件夹。
* Remove extension dirs.
*
* @param string $dirs
* @access private
* @return array
*/
private function removeExtensionDirs(string $dirs): array
{
$commandTips = array();
$appRoot = $this->app->getAppRoot();
$dirs = json_decode($dirs);
if($dirs)
{
rsort($dirs); // remove from the lower level directory.
foreach($dirs as $dir)
{
$path = rtrim($appRoot . $dir, '/');
if(!is_dir($path)) continue;
/* 如果没有权限或者删除失败则返回提示信息。*/
$parentDir = dirname($path);
if(!is_writable($path) || !is_writable($parentDir))
{
$commandTips[] = PHP_OS == 'Linux' ? "sudo rm -fr $appRoot$dir" : "rmdir $appRoot$dir /s /q";
}
elseif(!$this->classFile->removeDir($path))
{
$commandTips[] = PHP_OS == 'Linux' ? "sudo rm -fr $appRoot$dir" : "rmdir $appRoot$dir /s /q";
}
}
}
return $commandTips;
}
/**
* 删除禅道model的缓存文件。
* Clean model cache files.
*
* @access private
* @return true
*/
private function cleanModelCache()
{
$zfile = $this->app->loadClass('zfile');
$modelCacheFiles = glob($this->app->getTmpRoot() . 'model/*');
foreach($modelCacheFiles as $cacheFile)
{
if(is_dir($cacheFile))
{
$zfile->removeDir($cacheFile);
}
elseif(is_writable($cacheFile) && !is_dir($cacheFile))
{
@unlink($cacheFile);
}
}
return true;
}
}