diff --git a/modules/system/classes/PluginBase.php b/modules/system/classes/PluginBase.php index 30af4fbf88..d07d18db02 100644 --- a/modules/system/classes/PluginBase.php +++ b/modules/system/classes/PluginBase.php @@ -326,7 +326,7 @@ protected function getConfigurationFromYaml($exceptionMessage = null) $this->loadedYamlConfiguration = []; } else { - $this->loadedYamlConfiguration = Yaml::parse(file_get_contents($yamlFilePath)); + $this->loadedYamlConfiguration = Yaml::parseFile($yamlFilePath); if (!is_array($this->loadedYamlConfiguration)) { throw new SystemException(sprintf('Invalid format of the plugin configuration file: %s. The file should define an array.', $yamlFilePath)); } @@ -404,8 +404,6 @@ public function getPluginIdentifier(): string /** * Returns the absolute path to this plugin's directory - * - * @return string */ public function getPluginPath(): string { @@ -414,7 +412,7 @@ public function getPluginPath(): string } $reflection = new ReflectionClass($this); - $this->path = dirname($reflection->getFileName()); + $this->path = File::normalizePath(dirname($reflection->getFileName())); return $this->path; } @@ -435,7 +433,7 @@ public function getPluginVersion(): string if ( !File::isFile($versionFile) || !($versionInfo = Yaml::withProcessor(new VersionYamlProcessor, function ($yaml) use ($versionFile) { - return $yaml->parse(file_get_contents($versionFile)); + return $yaml->parseFile($versionFile); })) || !is_array($versionInfo) ) { @@ -448,4 +446,25 @@ public function getPluginVersion(): string return $this->version = trim(key(array_slice($versionInfo, -1, 1))); } + + /** + * Verifies the plugin's dependencies are present and enabled + */ + public function checkDependencies(PluginManager $manager): bool + { + $required = $manager->getDependencies($this); + if (empty($required)) { + return true; + } + + foreach ($required as $require) { + $requiredPlugin = $manager->findByIdentifier($require); + + if (!$requiredPlugin || $manager->isDisabled($requiredPlugin)) { + return false; + } + } + + return true; + } } diff --git a/modules/system/classes/PluginManager.php b/modules/system/classes/PluginManager.php index 6ca9d0a65b..f2ba9cdbbc 100644 --- a/modules/system/classes/PluginManager.php +++ b/modules/system/classes/PluginManager.php @@ -7,12 +7,15 @@ use File; use Lang; use View; +use Cache; use Config; use Schema; -use Storage; use SystemException; +use FilesystemIterator; use RecursiveIteratorIterator; use RecursiveDirectoryIterator; +use System\Models\PluginVersion; +use Winter\Storm\Foundation\Application; use Winter\Storm\Support\ClassLoader; use Backend\Classes\NavigationManager; @@ -26,20 +29,42 @@ class PluginManager { use \Winter\Storm\Support\Traits\Singleton; + // + // Disabled by system + // + + public const DISABLED_MISSING = 'disabled-missing'; + public const DISABLED_REPLACED = 'disabled-replaced'; + public const DISABLED_REPLACEMENT_FAILED = 'disabled-replacement-failed'; + public const DISABLED_MISSING_DEPENDENCIES = 'disabled-dependencies'; + + // + // Explicitly disabled for a reason + // + + public const DISABLED_REQUEST = 'disabled-request'; + public const DISABLED_BY_USER = 'disabled-user'; + public const DISABLED_BY_CONFIG = 'disabled-config'; + /** * The application instance, since Plugins are an extension of a Service Provider */ - protected $app; + protected Application $app; + + /** + * @var PluginBase[] Container array used for storing plugin information objects. + */ + protected $plugins = []; /** - * @var array Container array used for storing plugin information objects. + * @var array Array of plugin codes that contain any flags currently associated with the plugin */ - protected $plugins; + protected $pluginFlags = []; /** - * @var array A map of plugins and their directory paths. + * @var PluginVersion[] Local cache of loaded PluginVersion records keyed by plugin code */ - protected $pathMap = []; + protected $pluginRecords = []; /** * @var array A map of normalized plugin identifiers [lowercase.identifier => Normalized.Identifier] @@ -66,16 +91,6 @@ class PluginManager */ protected $booted = false; - /** - * @var string Path to the JSON encoded file containing the disabled plugins. - */ - protected $metaFile; - - /** - * @var array Array of disabled plugins - */ - protected $disabledPlugins = []; - /** * @var array Cache of registration method results. */ @@ -89,36 +104,25 @@ class PluginManager /** * Initializes the plugin manager */ - protected function init() + protected function init(): void { - $this->bindContainerObjects(); - $this->metaFile = 'cms/disabled.json'; - $this->loadDisabled(); - $this->loadPlugins(); + $this->app = App::make('app'); - if ($this->app->runningInBackend()) { - $this->loadDependencies(); - } + // Load the plugins from the filesystem and sort them by dependencies + $this->loadPlugins(); - $this->registerReplacedPlugins(); - } + // Loads the plugin flags (disabled & replacement states) from the cache + // regenerating them if required. + $this->loadPluginFlags(); - /** - * 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() - { - $this->app = App::make('app'); + // Register plugin replacements + $this->registerPluginReplacements(); } /** * Finds all available plugins and loads them in to the $this->plugins array. - * - * @return array */ - public function loadPlugins() + public function loadPlugins(): array { $this->plugins = []; @@ -129,7 +133,8 @@ public function loadPlugins() $this->loadPlugin($namespace, $path); } - $this->sortDependencies(); + // Sort all the plugins by number of dependencies + $this->sortByDependencies(); return $this->plugins; } @@ -139,9 +144,8 @@ public function loadPlugins() * * @param string $namespace Eg: Acme\Blog * @param string $path Eg: plugins_path().'/acme/blog'; - * @return void */ - public function loadPlugin($namespace, $path) + public function loadPlugin(string $namespace, string $path): ?PluginBase { $className = $namespace . '\Plugin'; $classPath = $path . '/Plugin.php'; @@ -154,10 +158,10 @@ public function loadPlugin($namespace, $path) // Not a valid plugin! if (!class_exists($className)) { - return; + return null; } - $classObj = new $className($this->app); + $pluginObj = new $className($this->app); } catch (\Throwable $e) { Log::error('Plugin ' . $className . ' could not be instantiated.', [ 'message' => $e->getMessage(), @@ -165,39 +169,80 @@ public function loadPlugin($namespace, $path) 'line' => $e->getLine(), 'trace' => $e->getTraceAsString() ]); - return; + return null; } - $classId = $this->getIdentifier($classObj); - - /* - * Check for disabled plugins - */ - if ($this->isDisabled($classId)) { - $classObj->disabled = true; - } + $classId = $this->getIdentifier($pluginObj); - $this->plugins[$classId] = $classObj; - $this->pathMap[$classId] = $path; + $this->plugins[$classId] = $pluginObj; $this->normalizedMap[strtolower($classId)] = $classId; - $replaces = $classObj->getReplaces(); + $replaces = $pluginObj->getReplaces(); if ($replaces) { foreach ($replaces as $replace) { $this->replacementMap[$replace] = $classId; } } - return $classObj; + return $pluginObj; + } + + /** + * Get the cache key for the current plugin manager state + */ + protected function getFlagCacheKey(): string + { + $loadedPlugins = array_keys($this->plugins); + $configDisabledPlugins = Config::get('cms.disablePlugins', []); + if (!is_array($configDisabledPlugins)) { + $configDisabledPlugins = []; + } + $plugins = $loadedPlugins + $configDisabledPlugins; + + return 'system.pluginmanager.state.' . md5(implode('.', $plugins)); + } + + /** + * Loads the plugin flags (disabled & replacement states) from the cache + * regenerating them if required. + */ + public function loadPluginFlags(): void + { + // Cache the data for a month so that stale keys can be autocleaned if necessary + $data = Cache::remember($this->getFlagCacheKey(), now()->addMonths(1), function () { + // Check the config files & database for plugins to disable + $this->loadDisabled(); + + // Check plugin dependencies for plugins to disable + $this->loadDependencies(); + + // Check plugin replacments for plugins to disable + $this->detectPluginReplacements(); + + return [ + $this->pluginFlags, + $this->replacementMap, + $this->activeReplacementMap, + ]; + }); + + list($this->pluginFlag, $this->replacementMap, $this->activeReplacementMap) = $data; + } + + /** + * Reset the plugin flag cache + */ + public function clearFlagCache(): void + { + Cache::forget($this->getFlagCacheKey()); } /** * 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 */ - public function registerAll($force = false) + public function registerAll(bool $force = false): void { if ($this->registered && !$force) { return; @@ -220,10 +265,8 @@ public function registerAll($force = false) /** * Unregisters all plugins: the inverse of registerAll(). - * - * @return void */ - public function unregisterAll() + public function unregisterAll(): void { $this->registered = false; $this->plugins = []; @@ -232,12 +275,8 @@ public function unregisterAll() /** * 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) + public function registerPlugin(PluginBase $plugin, ?string $pluginId = null): void { if (!$pluginId) { $pluginId = $this->getIdentifier($plugin); @@ -257,7 +296,7 @@ public function registerPlugin($plugin, $pluginId = null) /** * Prevent autoloaders from loading if plugin is disabled */ - if ($plugin->disabled) { + if ($this->isDisabled($pluginId)) { return; } @@ -292,7 +331,7 @@ public function registerPlugin($plugin, $pluginId = null) foreach ($replaces as $replace) { $replaceNamespace = $this->getNamespace($replace); - App::make(ClassLoader::class)->addNamespaceAliases([ + $this->app->make(ClassLoader::class)->addNamespaceAliases([ // class_alias() expects order to be $real, $alias $this->getNamespace($pluginId) => $replaceNamespace, ]); @@ -332,9 +371,8 @@ public function registerPlugin($plugin, $pluginId = null) * 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 */ - public function bootAll($force = false) + public function bootAll(bool $force = false): void { if ($this->booted && !$force) { return; @@ -349,13 +387,10 @@ public function bootAll($force = false) /** * Boots the provided plugin object. - * - * @param PluginBase $plugin - * @return void */ - public function bootPlugin($plugin) + public function bootPlugin(PluginBase $plugin): void { - if (!$plugin || $plugin->disabled || (self::$noInit && !$plugin->elevated)) { + if ((self::$noInit && !$plugin->elevated) || $this->isDisabled($plugin)) { return; } @@ -364,19 +399,10 @@ public function bootPlugin($plugin) /** * Returns the directory path to a plugin - * - * @param PluginBase|string $id The plugin to get the path for - * @return string|null */ - public function getPluginPath($id) + public function getPluginPath(PluginBase|string $plugin): ?string { - $classId = $this->getIdentifier($id); - $classId = $this->normalizeIdentifier($classId); - if (!isset($this->pathMap[$classId])) { - return null; - } - - return File::normalizePath($this->pathMap[$classId]); + return $this->findByIdentifier($plugin, true)?->getPluginPath(); } /** @@ -385,9 +411,9 @@ public function getPluginPath($id) * @param string $id Plugin identifier, eg: Namespace.PluginName * @return bool */ - public function exists($id) + public function exists(PluginBase|string $plugin): bool { - return $this->findByIdentifier($id) && !$this->isDisabled($id); + return $this->findByIdentifier($plugin) && !$this->isDisabled($plugin); } /** @@ -395,9 +421,9 @@ public function exists($id) * * @return array [$code => $pluginObj] */ - public function getPlugins() + public function getPlugins(): array { - return array_diff_key($this->plugins, $this->disabledPlugins); + return array_diff_key($this->plugins, $this->pluginFlags); } /** @@ -405,18 +431,15 @@ public function getPlugins() * * @return array [$code => $pluginObj] */ - public function getAllPlugins() + public function getAllPlugins(): array { return $this->plugins; } /** * Returns a plugin registration class based on its namespace (Author\Plugin). - * - * @param string $namespace - * @return PluginBase|null */ - public function findByNamespace($namespace) + public function findByNamespace(string $namespace): ?PluginBase { $identifier = $this->getIdentifier($namespace); @@ -425,20 +448,19 @@ public function findByNamespace($namespace) /** * Returns a plugin registration class based on its identifier (Author.Plugin). - * - * @param string|PluginBase $identifier - * @param bool $ignoreReplacements - * @return PluginBase|null */ - public function findByIdentifier($identifier, bool $ignoreReplacements = false) + public function findByIdentifier(PluginBase|string $identifier, bool $ignoreReplacements = false): ?PluginBase { + if ($identifier instanceof PluginBase) { + return $identifier; + } + if (!$ignoreReplacements && is_string($identifier) && isset($this->replacementMap[$identifier])) { $identifier = $this->replacementMap[$identifier]; } if (!isset($this->plugins[$identifier])) { - $code = $this->getIdentifier($identifier); - $identifier = $this->normalizeIdentifier($code); + $identifier = $this->getNormalizedIdentifier($identifier); } return $this->plugins[$identifier] ?? null; @@ -446,30 +468,25 @@ public function findByIdentifier($identifier, bool $ignoreReplacements = false) /** * Checks to see if a plugin has been registered. - * - * @param string|PluginBase - * @return bool */ - public function hasPlugin($namespace) + public function hasPlugin(PluginBase|string $plugin): bool { - $classId = $this->getIdentifier($namespace); - $normalized = $this->normalizeIdentifier($classId); + $normalized = $this->getNormalizedIdentifier($plugin); return isset($this->plugins[$normalized]) || isset($this->replacementMap[$normalized]); } /** * Returns a flat array of vendor plugin namespaces and their paths - * - * @return array ['Author\Plugin' => 'plugins/author/plugin'] + * ['Author\Plugin' => 'plugins/author/plugin'] */ - public function getPluginNamespaces() + public function getPluginNamespaces(): array { $classNames = []; foreach ($this->getVendorAndPluginNames() as $vendorName => $vendorList) { foreach ($vendorList as $pluginName => $pluginPath) { - $namespace = '\\'.$vendorName.'\\'.$pluginName; + $namespace = '\\' . $vendorName . '\\' . $pluginName; $namespace = Str::normalizeClassName($namespace); $classNames[$namespace] = $pluginPath; } @@ -480,26 +497,25 @@ public function getPluginNamespaces() /** * Returns a 2 dimensional array of vendors and their plugins. - * - * @return array ['vendor' => ['author' => 'plugins/author/plugin']] + * ['vendor' => ['author' => 'plugins/author/plugin']] */ - public function getVendorAndPluginNames() + public function getVendorAndPluginNames(): array { $plugins = []; - $dirPath = plugins_path(); + $dirPath = $this->app->pluginsPath(); if (!File::isDirectory($dirPath)) { return $plugins; } $it = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($dirPath, RecursiveDirectoryIterator::FOLLOW_SYMLINKS) + new RecursiveDirectoryIterator($dirPath, FilesystemIterator::FOLLOW_SYMLINKS) ); $it->setMaxDepth(2); $it->rewind(); while ($it->valid()) { - if (($it->getDepth() > 1) && $it->isFile() && (strtolower($it->getFilename()) == "plugin.php")) { + if (($it->getDepth() > 1) && $it->isFile() && (strtolower($it->getFilename()) === "plugin.php")) { $filePath = dirname($it->getPathname()); $pluginName = basename($filePath); $vendorName = basename(dirname($filePath)); @@ -513,14 +529,12 @@ public function getVendorAndPluginNames() } /** - * Resolves a plugin identifier (Author.Plugin) from a plugin class name or object. - * - * @param mixed Plugin class name or object - * @return string Identifier in format of Author.Plugin + * Resolves a plugin identifier (Author.Plugin) from a plugin class name + * (Author\Plugin) or PluginBase instance. */ - public function getIdentifier($namespace) + public function getIdentifier(PluginBase|string $plugin): string { - $namespace = Str::normalizeClassName($namespace); + $namespace = Str::normalizeClassName($plugin); if (strpos($namespace, '\\') === null) { return $namespace; } @@ -533,51 +547,48 @@ public function getIdentifier($namespace) } /** - * Resolves a plugin namespace (Author\Plugin) from a plugin class name, identifier or object. - * - * @param mixed Plugin class name, identifier or object - * @return string Namespace in format of Author\Plugin + * Resolves a plugin namespace (Author\Plugin) from a plugin class name + * (Author\Plugin\Classes\Example), identifier (Author.Plugin), or + * PluginBase instance. */ - public function getNamespace($identifier) + public function getNamespace(PluginBase|string $plugin): string { - if ( - is_object($identifier) - || (is_string($identifier) && strpos($identifier, '.') === null) - ) { - return Str::normalizeClassName($identifier); - } + if (is_string($plugin) && strpos($plugin, '.') !== null) { + $parts = explode('.', $plugin); + $slice = array_slice($parts, 0, 2); + $namespace = implode('\\', $slice); - $parts = explode('.', $identifier); - $slice = array_slice($parts, 0, 2); - $namespace = implode('\\', $slice); + return Str::normalizeClassName($namespace); + } - return Str::normalizeClassName($namespace); + return Str::normalizeClassName($plugin); } /** - * Takes a human plugin code (acme.blog) and makes it authentic (Acme.Blog) + * Normalizes the provided plugin identifier (author.plugin) and resolves + * it case-insensitively to the normalized identifier (Author.Plugin) * Returns the provided identifier if a match isn't found - * - * @param string $identifier - * @return string */ - public function normalizeIdentifier($identifier) + public function normalizeIdentifier(string $code): string { - $id = strtolower($identifier); - if (isset($this->normalizedMap[$id])) { - return $this->normalizedMap[$id]; - } + $code = strtolower($code); + return $this->normalizedMap[$code] ?? $code; + } - return $identifier; + /** + * Returns the normalized identifier (i.e. Winter.Blog) from the provided + * string or PluginBase instance. + */ + public function getNormalizedIdentifier(PluginBase|string $plugin): string + { + return $this->normalizeIdentifier($this->getIdentifier($plugin)); } /** - * Spins over every plugin object and collects the results of a method call. Results are cached in memory. - * - * @param string $methodName - * @return array + * Spins over every plugin object and collects the results of the provided + * method call. Results are cached in memory. */ - public function getRegistrationMethodValues($methodName) + public function getRegistrationMethodValues(string $methodName): array { if (isset($this->registrationMethodCache[$methodName])) { return $this->registrationMethodCache[$methodName]; @@ -598,157 +609,138 @@ public function getRegistrationMethodValues($methodName) } // - // Disability + // State Management (Disable, Enable, Freeze, Unfreeze) // + public function getPluginFlags(PluginBase|string $plugin): array + { + $code = $this->getNormalizedIdentifier($plugin); + return $this->pluginFlags[$code] ?? []; + } + /** - * Clears the disabled plugins cache file - * - * @return void + * Sets the provided flag on the provided plugin */ - public function clearDisabledCache() + protected function flagPlugin(PluginBase|string $plugin, string $flag): void { - Storage::delete($this->metaFile); - $this->disabledPlugins = []; + $code = $this->getNormalizedIdentifier($plugin); + $this->pluginFlags[$code][$flag] = true; } /** - * Loads all disabled plugins from the cached JSON file. - * - * @return void + * Removes the provided flag from the provided plugin */ - protected function loadDisabled() + protected function unflagPlugin(PluginBase|string $plugin, string $flag): void { - $path = $this->metaFile; + $code = $this->getNormalizedIdentifier($plugin); + unset($this->pluginFlags[$code][$flag]); + } + /** + * Loads all disabled plugins from the cached JSON file. + */ + protected function loadDisabled(): void + { + // Check the config files for disabled plugins if (($configDisabled = Config::get('cms.disablePlugins')) && is_array($configDisabled)) { foreach ($configDisabled as $disabled) { - $this->disabledPlugins[$disabled] = true; + $this->flagPlugin($disabled, static::DISABLED_BY_CONFIG); } } - if (Storage::exists($path)) { - $disabled = json_decode(Storage::get($path), true) ?: []; - $this->disabledPlugins = array_merge($this->disabledPlugins, $disabled); - } else { - $this->populateDisabledPluginsFromDb(); - $this->writeDisabled(); + // Check the database for disabled plugins + if ( + $this->app->hasDatabase() + && Schema::hasTable('system_plugin_versions') + ) { + $userDisabled = Db::table('system_plugin_versions')->where('is_disabled', 1)->lists('code') ?? []; + foreach ($userDisabled as $code) { + $this->flagPlugin($code, static::DISABLED_BY_USER); + } } } /** * Determines if a plugin is disabled by looking at the meta information * or the application configuration. - * - * @param string|PluginBase $id - * @return bool - */ - public function isDisabled($id) - { - $code = $this->getIdentifier($id); - $normalized = $this->normalizeIdentifier($code); - - return isset($this->disabledPlugins[$normalized]); - } - - /** - * Write the disabled plugins to a meta file. - * - * @return void - */ - protected function writeDisabled() - { - Storage::put($this->metaFile, json_encode($this->disabledPlugins)); - } - - /** - * Populates information about disabled plugins from database - * - * @return void */ - protected function populateDisabledPluginsFromDb() + public function isDisabled(PluginBase|string $plugin): bool { - if (!App::hasDatabase()) { - return; - } + $code = $this->getNormalizedIdentifier($plugin); - if (!Schema::hasTable('system_plugin_versions')) { - return; - } - - $disabled = Db::table('system_plugin_versions')->where('is_disabled', 1)->lists('code'); - - foreach ($disabled as $code) { - $this->disabledPlugins[$code] = true; - } + // @TODO: Limit this to only disabled flags if we add more than disabled flags + return !empty($this->pluginFlags[$code]); } /** * Returns the plugin replacements defined in $this->replacementMap - * - * @return array */ - public function getReplacementMap() + public function getReplacementMap(): array { return $this->replacementMap; } /** * Returns the actively replaced plugins defined in $this->activeReplacementMap - * @param string $pluginIdentifier Plugin code/namespace - * @return array|null */ - public function getActiveReplacementMap(string $pluginIdentifier = null) + public function getActiveReplacementMap(PluginBase|string $plugin = null): array|string|null { - if (!$pluginIdentifier) { - return $this->activeReplacementMap; - } - return $this->activeReplacementMap[$pluginIdentifier] ?? null; + return $plugin + ? $this->activeReplacementMap[$this->getNormalizedIdentifier($plugin)] ?? null + : $this->activeReplacementMap; } /** - * Evaluates and initializes the plugin replacements defined in $this->replacementMap - * - * @return void + * Evaluates the replacement map to determine which replacements can actually + * take effect */ - public function registerReplacedPlugins() + protected function detectPluginReplacements(): void { if (empty($this->replacementMap)) { return; } foreach ($this->replacementMap as $target => $replacement) { - // Alias the replaced plugin to the replacing plugin if the replaced plugin isn't present + // If the replaced plugin isn't present then assume it can be replaced if (!isset($this->plugins[$target])) { - $this->aliasPluginAs($replacement, $target); continue; } // Only allow one of the replaced plugin or the replacing plugin to exist // at once depending on whether the version constraints are met or not if ($this->plugins[$replacement]->canReplacePlugin($target, $this->plugins[$target]->getPluginVersion())) { - $this->aliasPluginAs($replacement, $target); - $this->disablePlugin($target); - $this->enablePlugin($replacement); - // Register this plugin as actively replaced + // Set the plugin flags to disable the target plugin + $this->flagPlugin($target, static::DISABLED_REPLACED); + $this->unflagPlugin($replacement, static::DISABLED_REPLACEMENT_FAILED); + + // Register this plugin as actively replaced (i.e. both are present, replaced are disabled) $this->activeReplacementMap[$target] = $replacement; } else { - $this->disablePlugin($replacement); - $this->enablePlugin($target); + // Set the plugin flags to disable the replacement plugin + $this->flagPlugin($replacement, static::DISABLED_REPLACEMENT_FAILED); + $this->unflagPlugin($target, static::DISABLED_REPLACED); + // Remove the replacement alias to prevent redirection to a disabled plugin unset($this->replacementMap[$target]); } } } + /** + * Executes the plugin replacements defined in the activeReplacementMap property + */ + protected function registerPluginReplacements(): void + { + foreach ($this->replacementMap as $target => $replacement) { + // Alias the replaced plugin to the replacing plugin + $this->aliasPluginAs($replacement, $target); + } + } + /** * Registers namespace aliasing for multiple subsystems - * - * @param string $namespace Plugin code - * @param string $alias Plugin alias code - * @return void */ - protected function aliasPluginAs(string $namespace, string $alias) + protected function aliasPluginAs(string $namespace, string $alias): void { Lang::registerNamespaceAlias($namespace, $alias); Config::registerNamespaceAlias($namespace, $alias); @@ -758,57 +750,101 @@ protected function aliasPluginAs(string $namespace, string $alias) } /** - * Disables a single plugin in the system. + * Get the PluginVersion record for the provided plugin * - * @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 + * @throws InvalidArgumentException if unable to find the requested plugin record in the database */ - public function disablePlugin($id, $isUser = false) + protected function getPluginRecord(PluginBase|string $plugin): PluginVersion { - $code = $this->getIdentifier($id); - $code = $this->normalizeIdentifier($code); - if (isset($this->disabledPlugins[$code])) { - return false; + $plugin = $this->getNormalizedIdentifier($plugin); + if (isset($this->pluginRecords[$plugin])) { + return $this->pluginRecords[$plugin]; } - $this->disabledPlugins[$code] = $isUser; - $this->writeDisabled(); + $record = PluginVersion::where('code', $plugin)->first(); - if ($pluginObj = $this->findByIdentifier($code, true)) { - $pluginObj->disabled = true; + if (!$record) { + throw new \InvalidArgumentException("$plugin was not found in the database."); } - return true; + return $this->pluginRecords[$plugin] = $record; } /** - * 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 + * Flags the provided plugin as "frozen" (updates cannot be downloaded / installed) + */ + public function freezePlugin(PluginBase|string $plugin): void + { + $record = $this->getPluginRecord($plugin); + $record->is_frozen = true; + $record->save(); + } + + /** + * "Unfreezes" the provided plugin, allowing for updates to be performed */ - public function enablePlugin($id, $isUser = false) + public function unfreezePlugin(PluginBase|string $plugin): void { - $code = $this->getIdentifier($id); - $code = $this->normalizeIdentifier($code); - if (!isset($this->disabledPlugins[$code])) { - return false; + $record = $this->getPluginRecord($plugin); + $record->is_frozen = false; + $record->save(); + } + + /** + * Disables the provided plugin using the provided flag (defaults to static::DISABLED_BY_USER) + */ + public function disablePlugin(PluginBase|string $plugin, string|bool $flag = self::DISABLED_BY_USER): bool + { + // $flag used to be (bool) $byUser + if ($flag === true) { + $flag = static::DISABLED_BY_USER; } - // Prevent system from enabling plugins disabled by the user - if (!$isUser && $this->disabledPlugins[$code] === true) { - return false; + // Flag the plugin as disabled + $this->flagPlugin($plugin, $flag); + + // Updates the database record for the plugin if required + if ($flag === static::DISABLED_BY_USER) { + $record = $this->getPluginRecord($plugin); + $record->is_disabled = true; + $record->save(); + + // Clear the cache so that the next request will regenerate the active flags + $this->clearFlagCache(); } - unset($this->disabledPlugins[$code]); - $this->writeDisabled(); + // Clear the registration values cache + $this->registrationMethodCache = []; - if ($pluginObj = $this->findByIdentifier($code, true)) { - $pluginObj->disabled = false; + return true; + } + + /** + * Enables the provided plugin using the provided flag (defaults to static::DISABLED_BY_USER) + */ + public function enablePlugin(PluginBase|string $plugin, $flag = self::DISABLED_BY_USER): bool + { + // $flag used to be (bool) $byUser + if ($flag === true) { + $flag = static::DISABLED_BY_USER; } + // Unflag the plugin as disabled + $this->unflagPlugin($plugin, $flag); + + // Updates the database record for the plugin if required + if ($flag === static::DISABLED_BY_USER) { + $record = $this->getPluginRecord($plugin); + $record->is_disabled = false; + $record->save(); + + // Clear the cache so that the next request will regenerate the active flags + $this->clearFlagCache(); + } + + // Clear the registration values cache + $this->registrationMethodCache = []; + return true; } @@ -816,6 +852,24 @@ public function enablePlugin($id, $isUser = false) // Dependencies // + /** + * Returns the plugin identifiers that are required by the supplied plugin. + */ + public function getDependencies(PluginBase|string $plugin): array + { + if (is_string($plugin) && (!$plugin = $this->findByIdentifier($plugin))) { + return []; + } + + if (!isset($plugin->require) || !$plugin->require) { + return []; + } + + return array_map(function ($require) { + return $this->replacementMap[$require] ?? $require; + }, is_array($plugin->require) ? $plugin->require : [$plugin->require]); + } + /** * Scans the system plugins to locate any dependencies that are not currently * installed. Returns an array of missing plugin codes keyed by the plugin that requires them. @@ -824,9 +878,8 @@ public function enablePlugin($id, $isUser = false) * * PluginManager::instance()->findMissingDependencies(); * - * @return array */ - public function findMissingDependencies() + public function findMissingDependencies(): array { $missing = []; @@ -850,32 +903,15 @@ public function findMissingDependencies() } /** - * Cross checks all plugins and their dependancies, if not met plugins - * are disabled and vice versa. - * - * @return void + * Checks plugin dependencies and flags plugins with missing dependencies as disabled */ - protected function loadDependencies() + protected function loadDependencies(): void { foreach ($this->plugins as $id => $plugin) { - if (!$required = $this->getDependencies($plugin)) { - continue; - } - - $disable = false; - - foreach ($required as $require) { - if (!$pluginObj = $this->findByIdentifier($require)) { - $disable = true; - } elseif ($pluginObj->disabled) { - $disable = true; - } - } - - if ($disable) { - $this->disablePlugin($id); + if (!$plugin->checkDependencies($this)) { + $this->flagPlugin($id, static::DISABLED_MISSING_DEPENDENCIES); } else { - $this->enablePlugin($id); + $this->unflagPlugin($id, static::DISABLED_MISSING_DEPENDENCIES); } } } @@ -887,7 +923,7 @@ protected function loadDependencies() * @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() + protected function sortByDependencies(): array { ksort($this->plugins); @@ -959,49 +995,14 @@ protected function sortDependencies() 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 array_map(function ($require) { - return $this->replacementMap[$require] ?? $require; - }, 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()); - } - // // Management // /** * Completely roll back and delete a plugin from the system. - * - * @param string $id Plugin code/namespace - * @return void */ - public function deletePlugin($id) + public function deletePlugin(string $id): void { /* * Rollback plugin @@ -1013,16 +1014,19 @@ public function deletePlugin($id) */ if ($pluginPath = self::instance()->getPluginPath($id)) { File::deleteDirectory($pluginPath); + + // Clear the registration values cache + $this->registrationMethodCache = []; + + // Clear the plugin flag cache + $this->clearFlagCache(); } } /** * Tears down a plugin's database tables and rebuilds them. - * - * @param string $id Plugin code/namespace - * @return void */ - public function refreshPlugin($id) + public function refreshPlugin(string $id): void { $manager = UpdateManager::instance(); $manager->rollbackPlugin($id); diff --git a/modules/system/classes/UpdateManager.php b/modules/system/classes/UpdateManager.php index 714cddbf76..a3bd2e0c7f 100644 --- a/modules/system/classes/UpdateManager.php +++ b/modules/system/classes/UpdateManager.php @@ -999,7 +999,8 @@ protected function applyHttpAttributes($http, $postData) $postData['server'] = base64_encode(serialize([ 'php' => PHP_VERSION, 'url' => Url::to('/'), - 'since' => PluginVersion::orderBy('created_at')->value('created_at') + // TODO: Store system boot date in `Parameter` + 'since' => PluginVersion::orderBy('created_at')->first()->created_at ])); if ($projectId = Parameter::get('system::project.id')) { diff --git a/modules/system/classes/VersionManager.php b/modules/system/classes/VersionManager.php index 0cb080cd48..a760e30ddb 100644 --- a/modules/system/classes/VersionManager.php +++ b/modules/system/classes/VersionManager.php @@ -346,7 +346,7 @@ protected function getFileVersions($code) $versionFile = $this->getVersionFile($code); $versionInfo = Yaml::withProcessor(new VersionYamlProcessor, function ($yaml) use ($versionFile) { - return $yaml->parse(file_get_contents($versionFile)); + return $yaml->parseFile($versionFile); }); if (!is_array($versionInfo)) { diff --git a/modules/system/console/PluginDisable.php b/modules/system/console/PluginDisable.php index 0169fb813a..f531ed3112 100644 --- a/modules/system/console/PluginDisable.php +++ b/modules/system/console/PluginDisable.php @@ -41,10 +41,6 @@ public function handle() // Disable this plugin $pluginManager->disablePlugin($pluginName); - $plugin = PluginVersion::where('code', $pluginName)->first(); - $plugin->is_disabled = true; - $plugin->save(); - $pluginManager->clearDisabledCache(); $this->output->writeln(sprintf('%s: disabled.', $pluginName)); } diff --git a/modules/system/console/PluginEnable.php b/modules/system/console/PluginEnable.php index eae8d3c772..f2ec3ae3e5 100644 --- a/modules/system/console/PluginEnable.php +++ b/modules/system/console/PluginEnable.php @@ -46,10 +46,6 @@ public function handle() // Enable this plugin $pluginManager->enablePlugin($pluginName); - $plugin = PluginVersion::where('code', $pluginName)->first(); - $plugin->is_disabled = false; - $plugin->save(); - $pluginManager->clearDisabledCache(); $this->output->writeln(sprintf('%s: enabled.', $pluginName)); } diff --git a/modules/system/controllers/Updates.php b/modules/system/controllers/Updates.php index 69b730c444..c953ec9f53 100644 --- a/modules/system/controllers/Updates.php +++ b/modules/system/controllers/Updates.php @@ -91,7 +91,7 @@ public function index() public function manage() { $this->pageTitle = 'system::lang.plugins.manage'; - PluginManager::instance()->clearDisabledCache(); + PluginManager::instance()->clearFlagCache(); return $this->asExtension('ListController')->index(); } @@ -238,6 +238,10 @@ protected function getWarnings() $warnings = []; $missingDependencies = PluginManager::instance()->findMissingDependencies(); + if (!empty($missingDependencies)) { + PluginManager::instance()->clearFlagCache(); + } + foreach ($missingDependencies as $pluginCode => $plugin) { foreach ($plugin as $missingPluginCode) { $warnings[] = Lang::get('system::lang.updates.update_warnings_plugin_missing', [ @@ -845,52 +849,45 @@ public function onBulkAction() count($checkedIds) ) { $manager = PluginManager::instance(); + $codes = PluginVersion::lists('code', 'id'); - foreach ($checkedIds as $pluginId) { - if (!$plugin = PluginVersion::find($pluginId)) { + foreach ($checkedIds as $id) { + $code = $codes[$id] ?? null; + if (!$code) { continue; } - $savePlugin = true; switch ($bulkAction) { // Enables plugin's updates. case 'freeze': - $plugin->is_frozen = 1; + $manager->freezePlugin($code); break; // Disables plugin's updates. case 'unfreeze': - $plugin->is_frozen = 0; + $manager->unfreezePlugin($code); break; // Disables plugin on the system. case 'disable': - $plugin->is_disabled = 1; - $manager->disablePlugin($plugin->code, true); + $manager->disablePlugin($code); break; // Enables plugin on the system. case 'enable': - $plugin->is_disabled = 0; - $manager->enablePlugin($plugin->code, true); + $manager->enablePlugin($code); break; // Rebuilds plugin database migrations. case 'refresh': - $savePlugin = false; - $manager->refreshPlugin($plugin->code); + $manager->refreshPlugin($code); break; // Rollback and remove plugins from the system. case 'remove': - $savePlugin = false; - $manager->deletePlugin($plugin->code); + $manager->deletePlugin($code); break; } - - if ($savePlugin) { - $plugin->save(); - } } } diff --git a/modules/system/models/PluginVersion.php b/modules/system/models/PluginVersion.php index 30b8ece054..14f2155dc8 100644 --- a/modules/system/models/PluginVersion.php +++ b/modules/system/models/PluginVersion.php @@ -2,7 +2,6 @@ use Lang; use Model; -use Config; use System\Classes\PluginManager; /** @@ -96,17 +95,22 @@ public function afterFetch() } } - if ($this->is_disabled) { - $manager->disablePlugin($this->code, true); - } - else { - $manager->enablePlugin($this->code, true); - } - - $this->disabledBySystem = $pluginObj->disabled; - - if (($configDisabled = Config::get('cms.disablePlugins')) && is_array($configDisabled)) { - $this->disabledByConfig = in_array($this->code, $configDisabled); + $activeFlags = $manager->getPluginFlags($pluginObj); + if (!empty($activeFlags)) { + foreach ($activeFlags as $flag => $enabled) { + if (in_array($flag, [ + PluginManager::DISABLED_MISSING, + PluginManager::DISABLED_REPLACED, + PluginManager::DISABLED_REPLACEMENT_FAILED, + PluginManager::DISABLED_MISSING_DEPENDENCIES, + ])) { + $this->disabledBySystem = true; + } + + if ($flag === PluginManager::DISABLED_BY_CONFIG) { + $this->disabledByConfig = true; + } + } } } else { @@ -118,9 +122,8 @@ public function afterFetch() /** * Returns true if the plugin should be updated by the system. - * @return bool */ - public function getIsUpdatableAttribute() + public function getIsUpdatableAttribute(): bool { return !$this->is_disabled && !$this->disabledBySystem && !$this->disabledByConfig; } @@ -128,7 +131,7 @@ public function getIsUpdatableAttribute() /** * Only include enabled plugins * @param $query - * @return mixed + * @return QueryBuilder */ public function scopeApplyEnabled($query) { @@ -137,10 +140,8 @@ public function scopeApplyEnabled($query) /** * Returns the current version for a plugin - * @param string $pluginCode Plugin code. Eg: Acme.Blog - * @return string */ - public static function getVersion($pluginCode) + public static function getVersion(string $pluginCode): ?string { if (self::$versionCache === null) { self::$versionCache = self::lists('version', 'code'); @@ -152,7 +153,7 @@ public static function getVersion($pluginCode) /** * Provides the slug attribute. */ - public function getSlugAttribute() + public function getSlugAttribute(): string { return self::makeSlug($this->code); } @@ -160,7 +161,7 @@ public function getSlugAttribute() /** * Generates a slug for the plugin. */ - public static function makeSlug($code) + public static function makeSlug(string $code): string { return strtolower(str_replace('.', '-', $code)); } diff --git a/modules/system/providers.php b/modules/system/providers.php index 9fb16b4893..fa44814b39 100644 --- a/modules/system/providers.php +++ b/modules/system/providers.php @@ -7,7 +7,6 @@ */ Illuminate\Broadcasting\BroadcastServiceProvider::class, Illuminate\Bus\BusServiceProvider::class, - Illuminate\Cache\CacheServiceProvider::class, Illuminate\Cookie\CookieServiceProvider::class, Illuminate\Encryption\EncryptionServiceProvider::class, Illuminate\Foundation\Providers\FoundationServiceProvider::class, @@ -22,6 +21,7 @@ /* * Winter Storm providers */ + Winter\Storm\Cache\CacheServiceProvider::class, Winter\Storm\Foundation\Providers\ConsoleSupportServiceProvider::class, Winter\Storm\Database\DatabaseServiceProvider::class, Winter\Storm\Halcyon\HalcyonServiceProvider::class, diff --git a/modules/system/reportwidgets/Status.php b/modules/system/reportwidgets/Status.php index f078642fea..157606b721 100644 --- a/modules/system/reportwidgets/Status.php +++ b/modules/system/reportwidgets/Status.php @@ -69,7 +69,8 @@ protected function loadData() $this->vars['requestLog'] = RequestLog::count(); $this->vars['requestLogMsg'] = LogSetting::get('log_requests', false) ? false : true; - $this->vars['appBirthday'] = PluginVersion::orderBy('created_at')->value('created_at'); + // TODO: Store system boot date in `Parameter` + $this->vars['appBirthday'] = PluginVersion::orderBy('created_at')->first()->created_at; } public function onLoadWarningsForm() diff --git a/tests/unit/system/classes/PluginManagerTest.php b/tests/unit/system/classes/PluginManagerTest.php index 8fe3a02a33..10667f5d29 100644 --- a/tests/unit/system/classes/PluginManagerTest.php +++ b/tests/unit/system/classes/PluginManagerTest.php @@ -1,22 +1,118 @@ set('database.connections.testing', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + $app['config']->set('database.default', 'testing'); + } + + return $app; + } + /** + * Perform test case set up. + * @return void + */ public function setUp() : void { + /* + * Force reload of Winter singletons + */ + PluginManager::forgetInstance(); + UpdateManager::forgetInstance(); + + // Forces plugin migrations to be run again on every test + VersionManager::forgetInstance(); + + $this->output = new \Symfony\Component\Console\Output\BufferedOutput(); + parent::setUp(); + /* + * Ensure system is up to date + */ + $this->runWinterUpCommand(); + $manager = PluginManager::instance(); self::callProtectedMethod($manager, 'loadDisabled'); $manager->loadPlugins(); self::callProtectedMethod($manager, 'loadDependencies'); - $this->manager = $manager; } + /** + * Flush event listeners and collect garbage. + * @return void + */ + public function tearDown() : void + { + $this->flushModelEventListeners(); + parent::tearDown(); + unset($this->app); + } + + /** + * Migrate database using winter:up command. + * @return void + */ + protected function runWinterUpCommand() + { + UpdateManager::instance() + ->setNotesOutput($this->output) + ->update(); + } + + /** + * The models in Winter use a static property to store their events, these + * will need to be targeted and reset ready for a new test cycle. + * Pivot models are an exception since they are internally managed. + * @return void + */ + protected function flushModelEventListeners() + { + foreach (get_declared_classes() as $class) { + if ($class === 'Winter\Storm\Database\Pivot' || strtolower($class) === 'october\rain\database\pivot') { + continue; + } + + $reflectClass = new ReflectionClass($class); + if ( + !$reflectClass->isInstantiable() || + !$reflectClass->isSubclassOf('Winter\Storm\Database\Model') || + $reflectClass->isSubclassOf('Winter\Storm\Database\Pivot') + ) { + continue; + } + + $class::flushEventListeners(); + } + + ActiveRecord::flushEventListeners(); + } + // // Tests // @@ -290,4 +386,56 @@ public function testActiveReplacementMap() $this->assertEquals('Winter.Replacement', $this->manager->getActiveReplacementMap('Winter.Original')); $this->assertNull($this->manager->getActiveReplacementMap('Winter.InvalidReplacement')); } + + public function testFlagDisableStatus() + { + $plugin = $this->manager->findByIdentifier('DependencyTest.Dependency'); + $flags = $this->manager->getPluginFlags($plugin); + $this->assertEmpty($flags); + + $plugin = $this->manager->findByIdentifier('DependencyTest.NotFound'); + $flags = $this->manager->getPluginFlags($plugin); + $this->assertCount(1, $flags); + $this->assertArrayHasKey(PluginManager::DISABLED_MISSING_DEPENDENCIES, $flags); + + $plugin = $this->manager->findByIdentifier('Winter.InvalidReplacement'); + $flags = $this->manager->getPluginFlags($plugin); + $this->assertCount(1, $flags); + $this->assertArrayHasKey(PluginManager::DISABLED_REPLACEMENT_FAILED, $flags); + + $plugin = $this->manager->findByIdentifier('Winter.Original', true); + $flags = $this->manager->getPluginFlags($plugin); + $this->assertCount(1, $flags); + $this->assertArrayHasKey(PluginManager::DISABLED_REPLACED, $flags); + } + + public function testFlagDisabling() + { + $plugin = $this->manager->findByIdentifier('Winter.Tester', true); + + $flags = $this->manager->getPluginFlags($plugin); + $this->assertEmpty($flags); + + $this->manager->disablePlugin($plugin); + + $flags = $this->manager->getPluginFlags($plugin); + $this->assertCount(1, $flags); + $this->assertArrayHasKey(PluginManager::DISABLED_BY_USER, $flags); + + $this->manager->enablePlugin($plugin); + + $flags = $this->manager->getPluginFlags($plugin); + $this->assertEmpty($flags); + + $this->manager->disablePlugin($plugin, PluginManager::DISABLED_BY_CONFIG); + + $flags = $this->manager->getPluginFlags($plugin); + $this->assertCount(1, $flags); + $this->assertArrayHasKey(PluginManager::DISABLED_BY_CONFIG, $flags); + + $this->manager->enablePlugin($plugin, PluginManager::DISABLED_BY_CONFIG); + + $flags = $this->manager->getPluginFlags($plugin); + $this->assertEmpty($flags); + } }