winter/modules/system/classes/PluginManager.php

859 lines
22 KiB
PHP
Raw Normal View History

2014-05-14 23:24:20 +10:00
<?php namespace System\Classes;
use Db;
2014-05-14 23:24:20 +10:00
use App;
use Str;
use Log;
2014-05-14 23:24:20 +10:00
use File;
use Lang;
use View;
use Config;
2018-08-18 12:51:43 +10:00
use Schema;
use SystemException;
2014-05-14 23:24:20 +10:00
use RecursiveIteratorIterator;
use RecursiveDirectoryIterator;
/**
* Plugin manager
*
* @package october\system
* @author Alexey Bobkov, Samuel Georges
*/
class PluginManager
{
use \October\Rain\Support\Traits\Singleton;
/**
* The application instance, since Plugins are an extension of a Service Provider
*/
protected $app;
/**
* @var array Container array used for storing plugin information objects.
2014-05-14 23:24:20 +10:00
*/
protected $plugins;
/**
* @var array A map of plugins and their directory paths.
*/
protected $pathMap = [];
/**
* @var array A map of normalized plugin identifiers [lowercase.identifier => Normalized.Identifier]
*/
protected $normalizedMap = [];
2014-05-14 23:24:20 +10:00
/**
* @var bool Flag to indicate that all plugins have had the register() method called by registerAll() being called on this class.
2014-05-14 23:24:20 +10:00
*/
protected $registered = false;
/**
* @var bool Flag to indicate that all plugins have had the boot() method called by bootAll() being called on this class.
2014-05-14 23:24:20 +10:00
*/
protected $booted = false;
/**
* @var string Path to the JSON encoded file containing the disabled plugins.
*/
2015-02-07 14:50:03 +11:00
protected $metaFile;
/**
* @var array Array of disabled plugins
*/
protected $disabledPlugins = [];
/**
* @var array Cache of registration method results.
*/
protected $registrationMethodCache = [];
/**
* @var bool Prevent all plugins from registering or booting
*/
public static $noInit = false;
2014-05-14 23:24:20 +10:00
/**
* Initializes the plugin manager
*/
protected function init()
{
$this->bindContainerObjects();
2016-01-15 10:07:39 +01:00
$this->metaFile = storage_path('cms/disabled.json');
$this->loadDisabled();
2014-05-14 23:24:20 +10:00
$this->loadPlugins();
if ($this->app->runningInBackend()) {
$this->loadDependencies();
}
}
2015-02-21 11:41:43 +11:00
/**
* These objects are "soft singletons" and may be lost when
* the IoC container reboots. This provides a way to rebuild
* for the purposes of unit testing.
*/
public function bindContainerObjects()
2015-02-21 11:41:43 +11:00
{
$this->app = App::make('app');
}
2014-05-14 23:24:20 +10:00
/**
* Finds all available plugins and loads them in to the $this->plugins array.
*
* @return array
2014-05-14 23:24:20 +10:00
*/
public function loadPlugins()
{
$this->plugins = [];
/**
* Locate all plugins and binds them to the container
*/
foreach ($this->getPluginNamespaces() as $namespace => $path) {
$this->loadPlugin($namespace, $path);
}
2014-05-14 23:24:20 +10:00
$this->sortDependencies();
return $this->plugins;
}
2014-05-14 23:24:20 +10:00
/**
* Loads a single plugin into the manager.
*
* @param string $namespace Eg: Acme\Blog
* @param string $path Eg: plugins_path().'/acme/blog';
* @return void
*/
public function loadPlugin($namespace, $path)
{
$className = $namespace . '\Plugin';
$classPath = $path . '/Plugin.php';
2014-05-14 23:24:20 +10:00
try {
// Autoloader failed?
if (!class_exists($className)) {
include_once $classPath;
}
// Not a valid plugin!
if (!class_exists($className)) {
return;
}
$classObj = new $className($this->app);
} catch (\Throwable $e) {
Log::error('Plugin ' . $className . ' could not be instantiated.', [
'message' => $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine(),
'trace' => $e->getTraceAsString()
]);
return;
}
$classId = $this->getIdentifier($classObj);
/*
* Check for disabled plugins
*/
if ($this->isDisabled($classId)) {
$classObj->disabled = true;
2014-05-14 23:24:20 +10:00
}
$this->plugins[$classId] = $classObj;
$this->pathMap[$classId] = $path;
$this->normalizedMap[strtolower($classId)] = $classId;
return $classObj;
2014-05-14 23:24:20 +10:00
}
/**
* Runs the register() method on all plugins. Can only be called once.
*
* @param bool $force Defaults to false, if true will force the re-registration of all plugins. Use unregisterAll() instead.
* @return void
2014-05-14 23:24:20 +10:00
*/
public function registerAll($force = false)
2014-05-14 23:24:20 +10:00
{
if ($this->registered && !$force) {
2014-05-14 23:24:20 +10:00
return;
2014-10-18 11:58:50 +02:00
}
2014-05-14 23:24:20 +10:00
foreach ($this->plugins as $pluginId => $plugin) {
$this->registerPlugin($plugin, $pluginId);
2014-05-14 23:24:20 +10:00
}
$this->registered = true;
}
/**
* Unregisters all plugins: the inverse of registerAll().
*
* @return void
*/
public function unregisterAll()
{
$this->registered = false;
$this->plugins = [];
}
/**
* Registers a single plugin object.
*
* @param PluginBase $plugin The instantiated Plugin object
* @param string $pluginId The string identifier for the plugin
* @return void
*/
public function registerPlugin($plugin, $pluginId = null)
{
if (!$pluginId) {
$pluginId = $this->getIdentifier($plugin);
}
if (!$plugin) {
return;
}
/**
* Verify that the provided plugin should be registered
*/
if ($plugin->disabled || (self::$noInit && !$plugin->elevated)) {
return;
}
$pluginPath = $this->getPluginPath($plugin);
$pluginNamespace = strtolower($pluginId);
/*
* Register language namespaces
*/
$langPath = $pluginPath . '/lang';
if (File::isDirectory($langPath)) {
Lang::addNamespace($pluginNamespace, $langPath);
}
/*
* Register plugin class autoloaders
*/
$autoloadPath = $pluginPath . '/vendor/autoload.php';
if (File::isFile($autoloadPath)) {
ComposerManager::instance()->autoload($pluginPath . '/vendor');
}
/**
* Run the plugin's register() method
*/
$plugin->register();
2015-03-03 14:34:38 +11:00
/*
* Register configuration path
*/
$configPath = $pluginPath . '/config';
if (File::isDirectory($configPath)) {
Config::package($pluginNamespace, $configPath, $pluginNamespace);
}
/*
* Register views path
*/
$viewsPath = $pluginPath . '/views';
if (File::isDirectory($viewsPath)) {
View::addNamespace($pluginNamespace, $viewsPath);
}
/*
* Add init, if available
*/
$initFile = $pluginPath . '/init.php';
if (File::exists($initFile)) {
require $initFile;
}
/*
* Add routes, if available
*/
$routesFile = $pluginPath . '/routes.php';
if (File::exists($routesFile)) {
require $routesFile;
}
}
2014-05-14 23:24:20 +10:00
/**
* Runs the boot() method on all plugins. Can only be called once.
*
* @param bool $force Defaults to false, if true will force the re-booting of all plugins
* @return void
2014-05-14 23:24:20 +10:00
*/
public function bootAll($force = false)
2014-05-14 23:24:20 +10:00
{
if ($this->booted && !$force) {
2014-05-14 23:24:20 +10:00
return;
2014-10-18 11:58:50 +02:00
}
2014-05-14 23:24:20 +10:00
foreach ($this->plugins as $plugin) {
$this->bootPlugin($plugin);
}
2014-05-14 23:24:20 +10:00
$this->booted = true;
}
/**
* Boots the provided plugin object.
*
* @param PluginBase $plugin
* @return void
*/
public function bootPlugin($plugin)
{
if (!$plugin || $plugin->disabled || (self::$noInit && !$plugin->elevated)) {
return;
}
$plugin->boot();
}
2014-05-14 23:24:20 +10:00
/**
* Returns the directory path to a plugin
*
* @param PluginBase|string $id The plugin to get the path for
* @return string|null
2014-05-14 23:24:20 +10:00
*/
public function getPluginPath($id)
{
$classId = $this->getIdentifier($id);
2014-10-18 11:58:50 +02:00
if (!isset($this->pathMap[$classId])) {
2014-05-14 23:24:20 +10:00
return null;
2014-10-18 11:58:50 +02:00
}
2014-05-14 23:24:20 +10:00
return File::normalizePath($this->pathMap[$classId]);
2014-05-14 23:24:20 +10:00
}
/**
* Check if a plugin exists and is enabled.
*
* @param string $id Plugin identifier, eg: Namespace.PluginName
* @return bool
*/
public function exists($id)
{
2018-08-15 19:26:20 +02:00
return !(!$this->findByIdentifier($id) || $this->isDisabled($id));
}
2014-05-14 23:24:20 +10:00
/**
* Returns an array with all enabled plugins
2014-05-14 23:24:20 +10:00
* The index is the plugin namespace, the value is the plugin information object.
*
* @return array
2014-05-14 23:24:20 +10:00
*/
public function getPlugins()
{
return array_diff_key($this->plugins, $this->disabledPlugins);
2014-05-14 23:24:20 +10:00
}
/**
* Returns a plugin registration class based on its namespace (Author\Plugin).
*
* @param string $namespace
* @return PluginBase|null
2014-05-14 23:24:20 +10:00
*/
public function findByNamespace($namespace)
{
$identifier = $this->getIdentifier($namespace);
2014-05-14 23:24:20 +10:00
return $this->plugins[$identifier] ?? null;
2014-05-14 23:24:20 +10:00
}
/**
* Returns a plugin registration class based on its identifier (Author.Plugin).
*
* @param string|PluginBase $identifier
* @return PluginBase|null
2014-05-14 23:24:20 +10:00
*/
public function findByIdentifier($identifier)
{
2014-10-18 11:58:50 +02:00
if (!isset($this->plugins[$identifier])) {
$code = $this->getIdentifier($identifier);
$identifier = $this->normalizeIdentifier($code);
2014-10-18 11:58:50 +02:00
}
return $this->plugins[$identifier] ?? null;
2014-05-14 23:24:20 +10:00
}
/**
* Checks to see if a plugin has been registered.
*
* @param string|PluginBase
* @return bool
2014-05-14 23:24:20 +10:00
*/
public function hasPlugin($namespace)
{
$classId = $this->getIdentifier($namespace);
$normalized = $this->normalizeIdentifier($classId);
return isset($this->plugins[$normalized]);
2014-05-14 23:24:20 +10:00
}
/**
* Returns a flat array of vendor plugin namespaces and their paths
*
* @return array ['Author\Plugin' => 'plugins/author/plugin']
2014-05-14 23:24:20 +10:00
*/
public function getPluginNamespaces()
{
$classNames = [];
foreach ($this->getVendorAndPluginNames() as $vendorName => $vendorList) {
foreach ($vendorList as $pluginName => $pluginPath) {
$namespace = '\\'.$vendorName.'\\'.$pluginName;
$namespace = Str::normalizeClassName($namespace);
$classNames[$namespace] = $pluginPath;
}
}
return $classNames;
}
/**
* Returns a 2 dimensional array of vendors and their plugins.
*
* @return array ['vendor' => ['author' => 'plugins/author/plugin']]
2014-05-14 23:24:20 +10:00
*/
public function getVendorAndPluginNames()
{
$plugins = [];
2015-02-07 15:37:07 +11:00
$dirPath = plugins_path();
2014-10-18 11:58:50 +02:00
if (!File::isDirectory($dirPath)) {
return $plugins;
2014-10-18 11:58:50 +02:00
}
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dirPath, RecursiveDirectoryIterator::FOLLOW_SYMLINKS)
);
2014-05-14 23:24:20 +10:00
$it->setMaxDepth(2);
2014-06-17 19:14:44 +10:00
$it->rewind();
2014-05-14 23:24:20 +10:00
while ($it->valid()) {
2014-05-14 23:24:20 +10:00
if (($it->getDepth() > 1) && $it->isFile() && (strtolower($it->getFilename()) == "plugin.php")) {
$filePath = dirname($it->getPathname());
$pluginName = basename($filePath);
$vendorName = basename(dirname($filePath));
$plugins[$vendorName][$pluginName] = $filePath;
}
$it->next();
}
return $plugins;
}
/**
* Resolves a plugin identifier (Author.Plugin) from a plugin class name or object.
*
2014-05-14 23:24:20 +10:00
* @param mixed Plugin class name or object
* @return string Identifier in format of Author.Plugin
2014-05-14 23:24:20 +10:00
*/
public function getIdentifier($namespace)
{
$namespace = Str::normalizeClassName($namespace);
2014-10-18 11:58:50 +02:00
if (strpos($namespace, '\\') === null) {
2014-05-14 23:24:20 +10:00
return $namespace;
2014-10-18 11:58:50 +02:00
}
2014-05-14 23:24:20 +10:00
$parts = explode('\\', $namespace);
$slice = array_slice($parts, 1, 2);
$namespace = implode('.', $slice);
2014-05-14 23:24:20 +10:00
return $namespace;
}
2014-05-21 16:39:28 +10:00
/**
* Takes a human plugin code (acme.blog) and makes it authentic (Acme.Blog)
* Returns the provided identifier if a match isn't found
*
* @param string $identifier
* @return string
*/
public function normalizeIdentifier($identifier)
{
$id = strtolower($identifier);
if (isset($this->normalizedMap[$id])) {
return $this->normalizedMap[$id];
}
return $identifier;
}
/**
* Spins over every plugin object and collects the results of a method call. Results are cached in memory.
*
* @param string $methodName
* @return array
*/
public function getRegistrationMethodValues($methodName)
{
if (isset($this->registrationMethodCache[$methodName])) {
return $this->registrationMethodCache[$methodName];
}
$results = [];
$plugins = $this->getPlugins();
foreach ($plugins as $id => $plugin) {
if (!method_exists($plugin, $methodName)) {
continue;
}
$results[$id] = $plugin->{$methodName}();
}
return $this->registrationMethodCache[$methodName] = $results;
}
2014-05-21 16:39:28 +10:00
//
// Disability
//
/**
* Clears the disabled plugins cache file
*
* @return void
*/
public function clearDisabledCache()
{
2015-02-07 14:50:03 +11:00
File::delete($this->metaFile);
$this->disabledPlugins = [];
}
2014-05-21 16:39:28 +10:00
/**
* Loads all disabled plugins from the cached JSON file.
*
* @return void
2014-05-21 16:39:28 +10:00
*/
protected function loadDisabled()
{
2015-02-07 14:50:03 +11:00
$path = $this->metaFile;
2014-05-21 16:39:28 +10:00
if (($configDisabled = Config::get('cms.disablePlugins')) && is_array($configDisabled)) {
2014-10-18 11:58:50 +02:00
foreach ($configDisabled as $disabled) {
2014-05-21 16:39:28 +10:00
$this->disabledPlugins[$disabled] = true;
2014-10-18 11:58:50 +02:00
}
2014-05-21 16:39:28 +10:00
}
if (File::exists($path)) {
$disabled = json_decode(File::get($path), true) ?: [];
2014-05-21 16:39:28 +10:00
$this->disabledPlugins = array_merge($this->disabledPlugins, $disabled);
} else {
$this->populateDisabledPluginsFromDb();
2014-05-21 16:39:28 +10:00
$this->writeDisabled();
}
}
/**
* Determines if a plugin is disabled by looking at the meta information
* or the application configuration.
*
* @param string|PluginBase $id
* @return bool
2014-05-21 16:39:28 +10:00
*/
public function isDisabled($id)
{
$code = $this->getIdentifier($id);
$normalized = $this->normalizeIdentifier($code);
return isset($this->disabledPlugins[$normalized]);
2014-05-21 16:39:28 +10:00
}
/**
* Write the disabled plugins to a meta file.
*
* @return void
2014-05-21 16:39:28 +10:00
*/
protected function writeDisabled()
{
File::put($this->metaFile, json_encode($this->disabledPlugins));
2014-05-21 16:39:28 +10:00
}
/**
* Populates information about disabled plugins from database
*
* @return void
*/
protected function populateDisabledPluginsFromDb()
{
if (!App::hasDatabase()) {
return;
}
2018-08-18 12:51:43 +10:00
if (!Schema::hasTable('system_plugin_versions')) {
return;
}
2018-08-21 13:24:59 +10:00
$disabled = Db::table('system_plugin_versions')->where('is_disabled', 1)->lists('code');
foreach ($disabled as $code) {
$this->disabledPlugins[$code] = true;
}
}
2014-05-21 16:39:28 +10:00
/**
* Disables a single plugin in the system.
*
* @param string|PluginBase $id Plugin code/namespace
* @param bool $isUser Set to true if disabled by the user, false by default
* @return bool Returns false if the plugin was already disabled, true otherwise
2014-05-21 16:39:28 +10:00
*/
public function disablePlugin($id, $isUser = false)
{
$code = $this->getIdentifier($id);
$code = $this->normalizeIdentifier($code);
2014-10-18 11:58:50 +02:00
if (array_key_exists($code, $this->disabledPlugins)) {
2014-05-21 16:39:28 +10:00
return false;
2014-10-18 11:58:50 +02:00
}
2014-05-21 16:39:28 +10:00
$this->disabledPlugins[$code] = $isUser;
$this->writeDisabled();
2014-10-18 11:58:50 +02:00
if ($pluginObj = $this->findByIdentifier($code)) {
2014-05-21 16:39:28 +10:00
$pluginObj->disabled = true;
2014-10-18 11:58:50 +02:00
}
2014-05-21 16:39:28 +10:00
return true;
}
/**
* Enables a single plugin in the system.
*
* @param string|PluginBase $id Plugin code/namespace
* @param bool $isUser Set to true if enabled by the user, false by default
* @return bool Returns false if the plugin wasn't already disabled or if the user disabled a plugin that the system is trying to re-enable, true otherwise
2014-05-21 16:39:28 +10:00
*/
public function enablePlugin($id, $isUser = false)
{
$code = $this->getIdentifier($id);
$code = $this->normalizeIdentifier($code);
2014-10-18 11:58:50 +02:00
if (!array_key_exists($code, $this->disabledPlugins)) {
2014-05-21 16:39:28 +10:00
return false;
2014-10-18 11:58:50 +02:00
}
2014-05-21 16:39:28 +10:00
// Prevent system from enabling plugins disabled by the user
2014-10-18 11:58:50 +02:00
if (!$isUser && $this->disabledPlugins[$code] === true) {
2014-05-21 16:39:28 +10:00
return false;
2014-10-18 11:58:50 +02:00
}
2014-05-21 16:39:28 +10:00
unset($this->disabledPlugins[$code]);
$this->writeDisabled();
2014-10-18 11:58:50 +02:00
if ($pluginObj = $this->findByIdentifier($code)) {
2014-05-21 16:39:28 +10:00
$pluginObj->disabled = false;
2014-10-18 11:58:50 +02:00
}
2014-05-21 16:39:28 +10:00
return true;
}
//
// Dependencies
//
/**
* Scans the system plugins to locate any dependencies that are not currently
* installed. Returns an array of plugin codes that are needed.
*
* PluginManager::instance()->findMissingDependencies();
*
* @return array
*/
public function findMissingDependencies()
{
$missing = [];
foreach ($this->plugins as $id => $plugin) {
if (!$required = $this->getDependencies($plugin)) {
continue;
}
foreach ($required as $require) {
if ($this->hasPlugin($require)) {
continue;
}
if (!in_array($require, $missing)) {
$missing[] = $require;
}
}
}
return $missing;
}
2014-10-04 09:56:38 +10:00
/**
* Cross checks all plugins and their dependancies, if not met plugins
* are disabled and vice versa.
*
* @return void
2014-10-04 09:56:38 +10:00
*/
protected function loadDependencies()
2014-10-04 09:56:38 +10:00
{
foreach ($this->plugins as $id => $plugin) {
2014-10-18 11:58:50 +02:00
if (!$required = $this->getDependencies($plugin)) {
continue;
2014-10-18 11:58:50 +02:00
}
$disable = false;
foreach ($required as $require) {
if (!$pluginObj = $this->findByIdentifier($require)) {
$disable = true;
} elseif ($pluginObj->disabled) {
$disable = true;
2014-10-18 11:58:50 +02:00
}
}
2014-10-18 11:58:50 +02:00
if ($disable) {
$this->disablePlugin($id);
} else {
$this->enablePlugin($id);
2014-10-18 11:58:50 +02:00
}
}
}
/**
* Sorts a collection of plugins, in the order that they should be actioned,
* according to their given dependencies. Least dependent come first.
*
* @return array Array of sorted plugin identifiers and instantiated classes ['Author.Plugin' => PluginBase]
* @throws SystemException If a possible circular dependency is detected
*/
protected function sortDependencies()
{
ksort($this->plugins);
/*
* Canvas the dependency tree
*/
$checklist = $this->plugins;
$result = [];
$loopCount = 0;
while (count($checklist)) {
if (++$loopCount > 2048) {
throw new SystemException('Too much recursion! Check for circular dependencies in your plugins.');
2014-10-18 11:58:50 +02:00
}
foreach ($checklist as $code => $plugin) {
/*
* Get dependencies and remove any aliens
*/
$depends = $this->getDependencies($plugin);
$depends = array_filter($depends, function ($pluginCode) {
return isset($this->plugins[$pluginCode]);
});
/*
* No dependencies
*/
if (!$depends) {
array_push($result, $code);
unset($checklist[$code]);
continue;
}
/*
* Find dependencies that have not been checked
*/
$depends = array_diff($depends, $result);
2014-10-18 11:58:50 +02:00
if (count($depends) > 0) {
continue;
2014-10-18 11:58:50 +02:00
}
/*
* All dependencies are checked
*/
array_push($result, $code);
unset($checklist[$code]);
}
}
/*
* Reassemble plugin map
*/
$sortedPlugins = [];
foreach ($result as $code) {
$sortedPlugins[$code] = $this->plugins[$code];
}
return $this->plugins = $sortedPlugins;
}
/**
* Returns the plugin identifiers that are required by the supplied plugin.
*
* @param string $plugin Plugin identifier, object or class
* @return array
*/
public function getDependencies($plugin)
{
if (is_string($plugin) && (!$plugin = $this->findByIdentifier($plugin))) {
return [];
}
if (!isset($plugin->require) || !$plugin->require) {
return [];
}
return is_array($plugin->require) ? $plugin->require : [$plugin->require];
}
/**
* @deprecated Plugins are now sorted by default. See getPlugins()
* Remove if year >= 2022
*/
public function sortByDependencies($plugins = null)
{
traceLog('PluginManager::sortByDependencies is deprecated. Plugins are now sorted by default. Use PluginManager::getPlugins()');
return array_keys($plugins ?: $this->getPlugins());
2014-10-04 09:56:38 +10:00
}
//
// Management
//
/**
* Completely roll back and delete a plugin from the system.
*
* @param string $id Plugin code/namespace
* @return void
*/
public function deletePlugin($id)
{
/*
* Rollback plugin
*/
UpdateManager::instance()->rollbackPlugin($id);
/*
* Delete from file system
*/
2018-08-15 18:47:06 +02:00
if ($pluginPath = self::instance()->getPluginPath($id)) {
File::deleteDirectory($pluginPath);
}
}
/**
* Tears down a plugin's database tables and rebuilds them.
*
* @param string $id Plugin code/namespace
* @return void
*/
public function refreshPlugin($id)
{
$manager = UpdateManager::instance();
$manager->rollbackPlugin($id);
$manager->updatePlugin($id);
}
2014-10-18 11:58:50 +02:00
}