diff --git a/modules/system/ServiceProvider.php b/modules/system/ServiceProvider.php index 43aa516e1..9fc858f39 100644 --- a/modules/system/ServiceProvider.php +++ b/modules/system/ServiceProvider.php @@ -253,6 +253,7 @@ class ServiceProvider extends ModuleServiceProvider $this->registerConsoleCommand('plugin.disable', 'System\Console\PluginDisable'); $this->registerConsoleCommand('plugin.enable', 'System\Console\PluginEnable'); $this->registerConsoleCommand('plugin.refresh', 'System\Console\PluginRefresh'); + $this->registerConsoleCommand('plugin.rollback', 'System\Console\PluginRollback'); $this->registerConsoleCommand('plugin.list', 'System\Console\PluginList'); $this->registerConsoleCommand('theme.install', 'System\Console\ThemeInstall'); diff --git a/modules/system/classes/UpdateManager.php b/modules/system/classes/UpdateManager.php index 0aed46961..7e4779de1 100644 --- a/modules/system/classes/UpdateManager.php +++ b/modules/system/classes/UpdateManager.php @@ -50,17 +50,17 @@ class UpdateManager protected $tempDirectory; /** - * @var System\Classes\PluginManager + * @var \System\Classes\PluginManager */ protected $pluginManager; /** - * @var Cms\Classes\ThemeManager + * @var \Cms\Classes\ThemeManager */ protected $themeManager; /** - * @var System\Classes\VersionManager + * @var \System\Classes\VersionManager */ protected $versionManager; @@ -85,12 +85,12 @@ class UpdateManager protected $productCache; /** - * @var Illuminate\Database\Migrations\Migrator + * @var \Illuminate\Database\Migrations\Migrator */ protected $migrator; /** - * @var Illuminate\Database\Migrations\DatabaseMigrationRepository + * @var \Illuminate\Database\Migrations\DatabaseMigrationRepository */ protected $repository; @@ -173,7 +173,7 @@ class UpdateManager /** * Checks for new updates and returns the amount of unapplied updates. * Only requests from the server at a set interval (retry timer). - * @param boolean $force Ignore the retry timer. + * @param boolean $force Ignore the retry timer. * @return int Number of unapplied updates. */ public function check($force = false) @@ -199,8 +199,7 @@ class UpdateManager try { $result = $this->requestUpdateList(); $newCount = array_get($result, 'update', 0); - } - catch (Exception $ex) { + } catch (Exception $ex) { $newCount = 0; } @@ -215,7 +214,7 @@ class UpdateManager /** * Requests an update list used for checking for new updates. - * @param boolean $force Request application and plugins hash list regardless of version. + * @param boolean $force Request application and plugins hash list regardless of version. * @return array */ public function requestUpdateList($force = false) @@ -234,11 +233,11 @@ class UpdateManager } $params = [ - 'core' => $this->getHash(), + 'core' => $this->getHash(), 'plugins' => serialize($versions), - 'themes' => serialize($themes), - 'build' => $build, - 'force' => $force + 'themes' => serialize($themes), + 'build' => $build, + 'force' => $force ]; $result = $this->requestServerData('core/update', $params); @@ -270,8 +269,7 @@ class UpdateManager (isset($updatable[$code]) && !$updatable[$code]) ) { $updateCount = max(0, --$updateCount); - } - else { + } else { $plugins[$code] = $info; } } @@ -312,7 +310,7 @@ class UpdateManager /** * Requests details about a project based on its identifier. - * @param string $projectId + * @param string $projectId * @return array */ public function requestProjectDetails($projectId) @@ -341,7 +339,7 @@ class UpdateManager $modules = Config::get('cms.loadModules', []); foreach ($modules as $module) { - $paths[] = $path = base_path() . '/modules/'.strtolower($module).'/database/migrations'; + $paths[] = $path = base_path() . '/modules/' . strtolower($module) . '/database/migrations'; } /* @@ -405,12 +403,12 @@ class UpdateManager */ public function migrateModule($module) { - $this->migrator->run(base_path() . '/modules/'.strtolower($module).'/database/migrations'); + $this->migrator->run(base_path() . '/modules/' . strtolower($module) . '/database/migrations'); $this->note($module); foreach ($this->migrator->getNotes() as $note) { - $this->note(' - '.$note); + $this->note(' - ' . $note); } return $this; @@ -423,7 +421,7 @@ class UpdateManager */ public function seedModule($module) { - $className = '\\'.$module.'\Database\Seeds\DatabaseSeeder'; + $className = '\\' . $module . '\Database\Seeds\DatabaseSeeder'; if (!class_exists($className)) { return; } @@ -532,11 +530,13 @@ class UpdateManager } /** - * Removes an existing plugin + * Rollback an existing plugin + * * @param string $name Plugin name. + * @param string $stopOnVersion If this parameter is specified, the process stops once the provided version number is reached * @return self */ - public function rollbackPlugin($name) + public function rollbackPlugin(string $name, string $stopOnVersion = null) { /* * Remove the plugin database and version @@ -548,8 +548,17 @@ class UpdateManager return $this; } - if ($this->versionManager->removePlugin($plugin)) { + if ($stopOnVersion && !$this->versionManager->hasDatabaseVersion($plugin, $stopOnVersion)) { + throw new ApplicationException(Lang::get('system::lang.updates.plugin_version_not_found')); + } + + if ($this->versionManager->removePlugin($plugin, $stopOnVersion, true)) { $this->note('Rolled back: ' . $name); + + if ($currentVersion = $this->versionManager->getCurrentVersion($plugin)) { + $this->note('Current Version: ' . $currentVersion . ' (' . $this->versionManager->getCurrentVersionNote($plugin) . ')'); + } + return $this; } @@ -569,7 +578,7 @@ class UpdateManager { $fileCode = $name . $hash; $this->requestServerFile('plugin/get', $fileCode, $hash, [ - 'name' => $name, + 'name' => $name, 'installation' => $installation ? 1 : 0 ]); } @@ -654,7 +663,7 @@ class UpdateManager $newCodes = array_diff($codes, array_keys($this->productCache[$type])); if (count($newCodes)) { $dataCodes = []; - $data = $this->requestServerData($type.'/details', ['names' => $newCodes]); + $data = $this->requestServerData($type . '/details', ['names' => $newCodes]); foreach ($data as $product) { $code = array_get($product, 'code', -1); $this->cacheProductDetail($type, $code, $product); @@ -697,13 +706,13 @@ class UpdateManager $type = 'plugin'; } - $cacheKey = 'system-updates-popular-'.$type; + $cacheKey = 'system-updates-popular-' . $type; if (Cache::has($cacheKey)) { return @unserialize(@base64_decode(Cache::get($cacheKey))) ?: []; } - $data = $this->requestServerData($type.'/popular'); + $data = $this->requestServerData($type . '/popular'); Cache::put($cacheKey, base64_encode(serialize($data)), 60); foreach ($data as $product) { @@ -723,8 +732,7 @@ class UpdateManager if (Cache::has($cacheKey)) { $this->productCache = @unserialize(@base64_decode(Cache::get($cacheKey))) ?: $defaultCache; - } - else { + } else { $this->productCache = $defaultCache; } } @@ -774,8 +782,7 @@ class UpdateManager try { $resultData = json_decode($result->body, true); - } - catch (Exception $ex) { + } catch (Exception $ex) { throw new ApplicationException(Lang::get('system::lang.server.response_invalid')); } @@ -788,15 +795,14 @@ class UpdateManager /** * Raise a note event for the migrator. - * @param string $message + * @param string $message * @return self */ protected function note($message) { if ($this->notesOutput !== null) { $this->notesOutput->writeln($message); - } - else { + } else { $this->notes[] = $message; } @@ -827,7 +833,7 @@ class UpdateManager /** * Sets an output stream for writing notes. - * @param Illuminate\Console\Command $output + * @param Illuminate\Console\Command $output * @return self */ public function setNotesOutput($output) @@ -843,8 +849,8 @@ class UpdateManager /** * Contacts the update server for a response. - * @param string $uri Gateway API URI - * @param array $postData Extra post data + * @param string $uri Gateway API URI + * @param array $postData Extra post data * @return array */ public function requestServerData($uri, $postData = []) @@ -869,8 +875,7 @@ class UpdateManager try { $resultData = @json_decode($result->body, true); - } - catch (Exception $ex) { + } catch (Exception $ex) { throw new ApplicationException(Lang::get('system::lang.server.response_invalid')); } @@ -883,10 +888,10 @@ class UpdateManager /** * Downloads a file from the update server. - * @param string $uri Gateway API URI - * @param string $fileCode A unique code for saving the file. - * @param string $expectedHash The expected file hash of the file. - * @param array $postData Extra post data + * @param string $uri Gateway API URI + * @param string $fileCode A unique code for saving the file. + * @param string $expectedHash The expected file hash of the file. + * @param array $postData Extra post data * @return void */ public function requestServerFile($uri, $fileCode, $expectedHash, $postData = []) @@ -910,7 +915,7 @@ class UpdateManager /** * Calculates a file path for a file code - * @param string $fileCode A unique file code + * @param string $fileCode A unique file code * @return string Full path on the disk */ protected function getFilePath($fileCode) @@ -921,7 +926,7 @@ class UpdateManager /** * Set the API security for all transmissions. - * @param string $key API Key + * @param string $key API Key * @param string $secret API Secret */ public function setSecurity($key, $secret) @@ -932,7 +937,7 @@ class UpdateManager /** * Create a complete gateway server URL from supplied URI - * @param string $uri URI + * @param string $uri URI * @return string URL */ protected function createServerUrl($uri) @@ -947,8 +952,8 @@ class UpdateManager /** * Modifies the Network HTTP object with common attributes. - * @param Http $http Network object - * @param array $postData Post data + * @param Http $http Network object + * @param array $postData Post data * @return void */ protected function applyHttpAttributes($http, $postData) diff --git a/modules/system/classes/VersionManager.php b/modules/system/classes/VersionManager.php index 55e8636fe..77127cf12 100644 --- a/modules/system/classes/VersionManager.php +++ b/modules/system/classes/VersionManager.php @@ -157,8 +157,13 @@ class VersionManager * Removes and packs down a plugin from the system. Files are left intact. * If the $stopOnVersion parameter is specified, the process stops after * the specified version is rolled back. + * + * @param mixed $plugin Either the identifier of a plugin as a string, or a Plugin class. + * @param string $stopOnVersion + * @param bool $stopCurrentVersion + * @return bool */ - public function removePlugin($plugin, $stopOnVersion = null) + public function removePlugin($plugin, $stopOnVersion = null, $stopCurrentVersion = false) { $code = is_string($plugin) ? $plugin : $this->pluginManager->getIdentifier($plugin); @@ -172,25 +177,37 @@ class VersionManager $stopOnNextVersion = false; $newPluginVersion = null; - foreach ($pluginHistory as $history) { - if ($stopOnNextVersion && $history->version !== $stopOnVersion) { - // Stop if the $stopOnVersion value was found and - // this is a new version. The history could contain - // multiple items for a single version (comments and scripts). - $newPluginVersion = $history->version; - break; - } + try { + foreach ($pluginHistory as $history) { + if ($stopCurrentVersion && $stopOnVersion === $history->version) { + $newPluginVersion = $history->version; + break; + } - if ($history->type == self::HISTORY_TYPE_COMMENT) { - $this->removeDatabaseComment($code, $history->version); - } - elseif ($history->type == self::HISTORY_TYPE_SCRIPT) { - $this->removeDatabaseScript($code, $history->version, $history->detail); - } + if ($stopOnNextVersion && $history->version !== $stopOnVersion) { + // Stop if the $stopOnVersion value was found and + // this is a new version. The history could contain + // multiple items for a single version (comments and scripts). + $newPluginVersion = $history->version; + break; + } - if ($stopOnVersion === $history->version) { - $stopOnNextVersion = true; + if ($history->type == self::HISTORY_TYPE_COMMENT) { + $this->removeDatabaseComment($code, $history->version); + } elseif ($history->type == self::HISTORY_TYPE_SCRIPT) { + $this->removeDatabaseScript($code, $history->version, $history->detail); + } + + if ($stopOnVersion === $history->version) { + $stopOnNextVersion = true; + } } + } catch (\Exception $exception) { + $lastHistory = $this->getLastHistory($code); + if ($lastHistory) { + $this->setDatabaseVersion($code, $lastHistory->version); + } + throw $exception; } $this->setDatabaseVersion($code, $newPluginVersion); @@ -209,7 +226,7 @@ class VersionManager /** * Deletes all records from the version and history tables for a plugin. - * @param string $pluginCode Plugin code + * @param string $pluginCode Plugin code * @return void */ public function purgePlugin($pluginCode) @@ -317,8 +334,7 @@ class VersionManager if (!isset($this->databaseVersions[$code])) { $this->databaseVersions[$code] = Db::table('system_plugin_versions') ->where('code', $code) - ->value('version') - ; + ->value('version'); } return $this->databaseVersions[$code] ?? self::NO_VERSION_VALUE; @@ -333,18 +349,16 @@ class VersionManager if ($version && !$currentVersion) { Db::table('system_plugin_versions')->insert([ - 'code' => $code, - 'version' => $version, + 'code' => $code, + 'version' => $version, 'created_at' => new Carbon ]); - } - elseif ($version && $currentVersion) { + } elseif ($version && $currentVersion) { Db::table('system_plugin_versions')->where('code', $code)->update([ - 'version' => $version, + 'version' => $version, 'created_at' => new Carbon ]); - } - elseif ($currentVersion) { + } elseif ($currentVersion) { Db::table('system_plugin_versions')->where('code', $code)->delete(); } @@ -357,10 +371,10 @@ class VersionManager protected function applyDatabaseComment($code, $version, $comment) { Db::table('system_plugin_history')->insert([ - 'code' => $code, - 'type' => self::HISTORY_TYPE_COMMENT, - 'version' => $version, - 'detail' => $comment, + 'code' => $code, + 'type' => self::HISTORY_TYPE_COMMENT, + 'version' => $version, + 'detail' => $comment, 'created_at' => new Carbon ]); } @@ -395,10 +409,10 @@ class VersionManager $this->updater->setUp($updateFile); Db::table('system_plugin_history')->insert([ - 'code' => $code, - 'type' => self::HISTORY_TYPE_SCRIPT, - 'version' => $version, - 'detail' => $script, + 'code' => $code, + 'type' => self::HISTORY_TYPE_SCRIPT, + 'version' => $version, + 'detail' => $script, 'created_at' => new Carbon ]); } @@ -440,6 +454,20 @@ class VersionManager return $this->databaseHistory[$code] = $historyInfo; } + /** + * Returns the last update history for a plugin. + * + * @param string $code The plugin identifier + * @return stdClass|null + */ + protected function getLastHistory($code) + { + return Db::table('system_plugin_history') + ->where('code', $code) + ->orderBy('id', 'DESC') + ->first(); + } + /** * Checks if a plugin has an applied update version. */ @@ -473,15 +501,14 @@ class VersionManager /** * Raise a note event for the migrator. - * @param string $message + * @param string $message * @return void */ protected function note($message) { if ($this->notesOutput !== null) { $this->notesOutput->writeln($message); - } - else { + } else { $this->notes[] = $message; } @@ -512,7 +539,7 @@ class VersionManager /** * Sets an output stream for writing notes. - * @param Illuminate\Console\Command $output + * @param Illuminate\Console\Command $output * @return self */ public function setNotesOutput($output) @@ -527,7 +554,7 @@ class VersionManager * * @return array */ - protected function extractScriptsAndComments($details) + protected function extractScriptsAndComments($details): array { if (is_array($details)) { $fileNamePattern = "/^[a-z0-9\_\-\.\/\\\]+\.php$/i"; @@ -546,4 +573,52 @@ class VersionManager return [$comments, $scripts]; } + + /** + * Get the currently installed version of the plugin. + * + * @param string|PluginBase $plugin Either the identifier of a plugin as a string, or a Plugin class. + * @return string + */ + public function getCurrentVersion($plugin): string + { + $code = $this->pluginManager->getIdentifier($plugin); + return $this->getDatabaseVersion($code); + } + + /** + * Check if a certain version of the plugin exists in the plugin history database. + * + * @param string|PluginBase $plugin Either the identifier of a plugin as a string, or a Plugin class. + * @param string $version + * @return bool + */ + public function hasDatabaseVersion($plugin, string $version): bool + { + $code = $this->pluginManager->getIdentifier($plugin); + $histories = $this->getDatabaseHistory($code); + foreach ($histories as $history) { + if ($history->version === $version) { + return true; + } + } + + return false; + } + + /** + * Get last version note + * + * @param string|PluginBase $plugin + * @return string + */ + public function getCurrentVersionNote($plugin): string + { + $code = $this->pluginManager->getIdentifier($plugin); + $histories = $this->getDatabaseHistory($code); + $lastHistory = array_last(array_where($histories, function ($history) { + return $history->type === self::HISTORY_TYPE_COMMENT; + })); + return $lastHistory ? $lastHistory->detail : ''; + } } diff --git a/modules/system/console/PluginRollback.php b/modules/system/console/PluginRollback.php new file mode 100644 index 000000000..af4d2065c --- /dev/null +++ b/modules/system/console/PluginRollback.php @@ -0,0 +1,88 @@ +argument('name'); + $pluginName = PluginManager::instance()->normalizeIdentifier($pluginName); + if (!PluginManager::instance()->exists($pluginName)) { + throw new \InvalidArgumentException('Plugin not found'); + } + + $stopOnVersion = ltrim(($this->argument('version') ?: null), 'v'); + + if ($stopOnVersion) { + if (!VersionManager::instance()->hasDatabaseVersion($pluginName, $stopOnVersion)) { + throw new \InvalidArgumentException('Plugin version not found'); + } + $confirmQuestion = 'Please confirm that you wish to revert the plugin to version ' . $stopOnVersion . '. This may result in changes to your database and potential data loss.'; + } else { + $confirmQuestion = 'Please confirm that you wish to completely rollback this plugin. This may result in potential data loss.'; + } + + if ($this->option('force') || $this->confirm($confirmQuestion)) { + $manager = UpdateManager::instance()->setNotesOutput($this->output); + $stopOnVersion = ltrim(($this->argument('version') ?: null), 'v'); + + try { + $manager->rollbackPlugin($pluginName, $stopOnVersion); + } catch (\Exception $exception) { + $lastVersion = VersionManager::instance()->getCurrentVersion($pluginName); + $this->output->writeln(sprintf("An exception occurred during the rollback and the process has been stopped. The plugin was rolled back to version v%s.", $lastVersion)); + throw $exception; + } + } + } + + /** + * Get the console command arguments. + * @return array + */ + protected function getArguments() + { + return [ + ['name', InputArgument::REQUIRED, 'The name of the plugin to be rolled back. Eg: AuthorName.PluginName'], + ['version', InputArgument::OPTIONAL, 'If this parameter is specified, the process will stop on the specified version, if not, it will completely rollback the plugin. Example: 1.3.9'], + ]; + } + + protected function getOptions() + { + return [ + ['force', 'f', InputOption::VALUE_NONE, 'Force rollback', null], + ]; + } +} diff --git a/modules/system/lang/en/lang.php b/modules/system/lang/en/lang.php index 760bfde5d..3adf7f6c9 100644 --- a/modules/system/lang/en/lang.php +++ b/modules/system/lang/en/lang.php @@ -322,6 +322,7 @@ return [ 'plugin_version' => 'Version', 'plugin_author' => 'Author', 'plugin_not_found' => 'Plugin not found', + 'plugin_version_not_found' => 'Plugin version not found', 'core_current_build' => 'Current build', 'core_view_changelog' => 'View Changelog', 'core_build' => 'Build :build', diff --git a/modules/system/lang/pt-br/lang.php b/modules/system/lang/pt-br/lang.php index 6f080515c..adbaa043c 100644 --- a/modules/system/lang/pt-br/lang.php +++ b/modules/system/lang/pt-br/lang.php @@ -276,7 +276,7 @@ return [ 'plugin_description' => 'Descrição', 'plugin_version' => 'Versão', 'plugin_author' => 'Autor', - 'plugin_not_found' => 'Plugin encotrado', + 'plugin_not_found' => 'Plugin não encontrado', 'plugin_version_not_found' => 'Versão do plugin não encontrada', 'core_current_build' => 'Compilação atual', 'core_view_changelog' => 'Visualizar Changelog',