<?php
/**
* @file classes/plugins/PluginHelper.php
*
* Copyright (c) 2014-2021 Simon Fraser University
* Copyright (c) 2003-2021 John Willinsky
* Distributed under the GNU GPL v3. For full terms see the file docs/COPYING.
*
* @class PluginHelper
*
* @ingroup classes_plugins
*
* @brief Helper class implementing plugin administration functions.
*/
namespace PKP\plugins;
use APP\install\Install;
use APP\install\Upgrade;
use DirectoryIterator;
use Exception;
use Illuminate\Support\Arr;
use PharData;
use PKP\config\Config;
use PKP\core\Core;
use PKP\db\DAORegistry;
use PKP\file\FileManager;
use PKP\site\SiteDAO;
use PKP\site\Version;
use PKP\site\VersionCheck;
use PKP\site\VersionDAO;
use Throwable;
class PluginHelper
{
public const PLUGIN_ACTION_UPLOAD = 'upload';
public const PLUGIN_ACTION_UPGRADE = 'upgrade';
public const PLUGIN_VERSION_FILE = 'version.xml';
public const PLUGIN_INSTALL_FILE = 'install.xml';
public const PLUGIN_UPGRADE_FILE = 'upgrade.xml';
/**
* Extract the plugin, executes the callback, then cleanup the files
*
* @template T of mixed
*
* @param string $filePath Full path to plugin archive
* @param string $originalFileName Original filename of plugin archive
* @param callable(string $pluginDirectory):T $onExtracted The function will receive as parameter the directory of the plugin
*
* @throws Exception If any unexpected error happens (failure to unpack, absence of version.xml, etc)
*
* @return T Returns the result of the $onExtracted call
*/
private function extractPlugin(string $filePath, string $originalFileName, callable $onExtracted): mixed
{
$fileManager = new FileManager();
$extension = $this->sanitizeFilename($fileManager->parseFileExtension($originalFileName));
$baseName = $this->sanitizeFilename(basename($originalFileName, ".{$extension}")) ?: 'plugin';
// If the extension doesn't match the original one, copy (we don't know the original file) the file to another location to avoid issues with the PharData class
$filePathWithExtension = null;
if ($fileManager->parseFileExtension($filePath) !== $extension) {
$filePathWithExtension = ($fileManager->getTemporaryFile($baseName, ".{$extension}"))->getPathname();
$fileManager->copyFile($filePath, $filePathWithExtension) || throw new Exception('Failed to copy plugin file');
}
$extractPath = null;
try {
// Create a random directory to avoid symlink attacks.
$extractPath = rtrim(sys_get_temp_dir(), '\\/') . "/{$baseName}" . substr(md5(random_int(0, PHP_INT_MAX)), 0, 10) . '/';
$fileManager->mkdir($extractPath) || throw new Exception("Could not create directory {$extractPath}");
// Extract files
(new PharData($filePathWithExtension ?? $filePath))->extractTo($extractPath, null, true);
// Ensure there's a file named "version.xml" at the main directory or at the direct sub-directories
foreach (new DirectoryIterator($extractPath) as $current) {
if ($current->isDir() && $current->getBasename() !== '..' && is_file(($path = "{$current->getPathname()}/") . static::PLUGIN_VERSION_FILE)) {
return $onExtracted($path);
}
}
throw new Exception(__('manager.plugins.invalidPluginArchive'));
} finally {
// Cleanup the extracted folder on failure and rethrow
if ($extractPath) {
$fileManager->rmtree($extractPath);
}
// Cleanup the temporary archive file in case it was created
if ($filePathWithExtension) {
unlink($filePathWithExtension);
}
}
}
/**
* Installs an extracted plugin
*
* @param string $path path to plugin archive
* @param string $originalFileName Original filename of plugin archive
*
* @return Version Version of installed plugin on success
*/
public function installPlugin(string $path, string $originalFileName): Version
{
return $this->extractPlugin($path, $originalFileName, function (string $pluginFolder): Version {
$fileManager = new FileManager();
$versionFile = $pluginFolder . static::PLUGIN_VERSION_FILE;
$pluginVersion = VersionCheck::getValidPluginVersionInfo($versionFile);
/** @var VersionDAO */
$versionDao = DAORegistry::getDAO('VersionDAO');
$installedPlugin = $versionDao->getCurrentVersion($pluginVersion->getProductType(), $pluginVersion->getProduct());
$baseDir = Core::getBaseDir() . '/';
$destinyPath = $baseDir . strtr($pluginVersion->getProductType(), '.', '/') . "/{$pluginVersion->getProduct()}";
if ($installedPlugin && is_dir($destinyPath)) {
throw new Exception(
$installedPlugin->compare($pluginVersion) < 0
? __('manager.plugins.pleaseUpgrade')
: __('manager.plugins.installedVersionNewest')
);
}
// Copy the plug-in from the temporary folder to the target folder.
$fileManager->copyDir($pluginFolder, $destinyPath) || throw new Exception('Failed to copy plugin to destination folder');
try {
// Upgrade the database with the new plug-in.
$installFile = Arr::first(
["{$destinyPath}/" . static::PLUGIN_INSTALL_FILE, $baseDir . PKP_LIB_PATH . '/xml/defaultPluginInstall.xml'],
fn (string $path) => is_file($path)
)
?? throw new Exception('Missing installation file');
$siteDao = DAORegistry::getDAO('SiteDAO'); /** @var SiteDAO $siteDao */
$site = $siteDao->getSite();
$params = $this->_getConnectionParams();
$params['locale'] = $site->getPrimaryLocale();
$params['additionalLocales'] = $site->getSupportedLocales();
$installer = new Install($params, $installFile, true);
$installer->setCurrentVersion($pluginVersion);
$installer->execute() || throw new Exception(__('manager.plugins.installFailed', ['errorString' => $installer->getErrorString()]));
$versionDao->insertVersion($pluginVersion, true);
return $pluginVersion;
} catch (Throwable $e) {
// Delete the plugin files on failure
$fileManager->rmtree($destinyPath);
throw $e;
}
});
}
/**
* Load database connection parameters into an array (needed for upgrade).
*/
protected function _getConnectionParams(): array
{
return [
'connectionCharset' => Config::getVar('i18n', 'connection_charset'),
'databaseDriver' => Config::getVar('database', 'driver'),
'databaseHost' => Config::getVar('database', 'host'),
'databasePort' => Config::getVar('database', 'port'),
'unixSocket' => Config::getVar('database', 'unix_socket'),
'databaseUsername' => Config::getVar('database', 'username'),
'databasePassword' => Config::getVar('database', 'password'),
'databaseName' => Config::getVar('database', 'name')
];
}
/**
* Upgrade a plugin to a newer version from the user's filesystem
*
* @param string $path path to plugin archive
* @param string $originalFileName Original filename of plugin archive
*
*/
public function upgradePlugin(string $category, string $plugin, string $path, string $originalFileName): Version
{
return $this->extractPlugin($path, $originalFileName, function (string $pluginFolder) use ($category, $plugin): Version {
$fileManager = new FileManager();
$versionFile = $pluginFolder . static::PLUGIN_VERSION_FILE;
$pluginVersion = VersionCheck::getValidPluginVersionInfo($versionFile);
// Check whether the uploaded plug-in fits the original plug-in.
if ("plugins.{$category}" !== $pluginVersion->getProductType()) {
throw new Exception(__('manager.plugins.wrongCategory'));
}
if ($plugin !== $pluginVersion->getProduct()) {
throw new Exception(__('manager.plugins.wrongName'));
}
$versionDao = DAORegistry::getDAO('VersionDAO'); /** @var VersionDAO $versionDao */
$installedPlugin = $versionDao->getCurrentVersion($pluginVersion->getProductType(), $pluginVersion->getProduct());
if (!$installedPlugin) {
throw new Exception(__('manager.plugins.pleaseInstall'));
}
if ($installedPlugin->compare($pluginVersion) >= 0) {
throw new Exception(__('manager.plugins.installedVersionNewer'));
}
$destinyPath = Core::getBaseDir() . "/plugins/{$category}/{$plugin}";
// Delete existing files.
$fileManager->rmtree($destinyPath);
// Check whether deleting has worked.
if (is_dir($destinyPath)) {
throw new Exception(__('manager.plugins.deleteError', ['pluginName' => $pluginVersion->getProduct()]));
}
// Copy the plug-in from the temporary folder to the target folder.
$fileManager->copyDir($pluginFolder, $destinyPath) || throw new Exception('Could not copy plugin to destination!');
try {
$upgradeFile = "{$destinyPath}/" . static::PLUGIN_UPGRADE_FILE;
if ($fileManager->fileExists($upgradeFile)) {
/** @var SiteDAO */
$siteDao = DAORegistry::getDAO('SiteDAO');
$site = $siteDao->getSite();
$params = $this->_getConnectionParams();
$params['locale'] = $site->getPrimaryLocale();
$params['additionalLocales'] = $site->getSupportedLocales();
$installer = new Upgrade($params, $upgradeFile, true);
// Run the upgrade/migration
$installer->execute() || throw new Exception(__('manager.plugins.upgradeFailed', ['errorString' => $installer->getErrorString()]));
}
// Add the new version to the database
$pluginVersion->setCurrent(1);
$versionDao->insertVersion($pluginVersion, true);
return $pluginVersion;
} catch (Throwable $e) {
// Delete the plugin files on failure
$fileManager->rmtree($destinyPath);
throw $e;
}
});
}
/**
* Drops risky characters from a filename
*/
private function sanitizeFilename(string $filename): string
{
return preg_replace('/[^\w.-]/', '', $filename);
}
}
if (!PKP_STRICT_MODE) {
class_alias('\PKP\plugins\PluginHelper', '\PluginHelper');
foreach ([
'PLUGIN_ACTION_UPLOAD',
'PLUGIN_ACTION_UPGRADE',
'PLUGIN_VERSION_FILE',
'PLUGIN_INSTALL_FILE',
'PLUGIN_UPGRADE_FILE',
] as $constantName) {
define($constantName, constant('\PluginHelper::' . $constantName));
}
}
|