diff --git a/modules/system/classes/PluginManager.php b/modules/system/classes/PluginManager.php index 025584b7da..fc14126b80 100644 --- a/modules/system/classes/PluginManager.php +++ b/modules/system/classes/PluginManager.php @@ -4,7 +4,6 @@ use Backend\Classes\NavigationManager; use FilesystemIterator; -use Illuminate\Database\QueryException; use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; @@ -14,7 +13,7 @@ use RecursiveDirectoryIterator; use RecursiveIteratorIterator; use System\Models\PluginVersion; -use SystemException; +use Winter\Storm\Exception\SystemException; use Winter\Storm\Foundation\Application; use Winter\Storm\Support\ClassLoader; use Winter\Storm\Support\Facades\Config; diff --git a/modules/system/classes/UpdateManager.php b/modules/system/classes/UpdateManager.php index 8a9569558c..55374ac690 100644 --- a/modules/system/classes/UpdateManager.php +++ b/modules/system/classes/UpdateManager.php @@ -1,23 +1,31 @@ -getMigrationTableName()); @@ -212,10 +195,9 @@ public function update() /** * 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. - * @return int Number of unapplied updates. + * @param $force Ignore the retry timer. */ - public function check($force = false) + public function check(bool $force = false): int { /* * Already know about updates, never retry. @@ -253,10 +235,9 @@ public function check($force = false) /** * Requests an update list used for checking for new updates. - * @param boolean $force Request application and plugins hash list regardless of version. - * @return array + * @param $force Request application and plugins hash list regardless of version. */ - public function requestUpdateList($force = false) + public function requestUpdateList(bool $force = false): array { $installed = PluginVersion::all(); $versions = $installed->lists('version', 'code'); @@ -349,19 +330,16 @@ public function requestUpdateList($force = false) /** * Requests details about a project based on its identifier. - * @param string $projectId - * @return array */ - public function requestProjectDetails($projectId) + public function requestProjectDetails(string $projectId): array { return $this->requestServerData('project/detail', ['id' => $projectId]); } /** * Roll back all modules and plugins. - * @return self */ - public function uninstall() + public function uninstall(): static { /* * Rollback plugins @@ -411,10 +389,9 @@ public function uninstall() * to the code = less confidence. * - `changes`: If $detailed is true, this will include the list of files modified, created and deleted. * - * @param bool $detailed If true, the list of files modified, added and deleted will be included in the result. - * @return array + * @param $detailed If true, the list of files modified, added and deleted will be included in the result. */ - public function getBuildNumberManually($detailed = false) + public function getBuildNumberManually(bool $detailed = false): array { $source = new SourceManifest(); $manifest = new FileManifest(null, null, true); @@ -426,10 +403,9 @@ public function getBuildNumberManually($detailed = false) /** * Sets the build number in the database. * - * @param bool $detailed If true, the list of files modified, added and deleted will be included in the result. - * @return void + * @param $detailed If true, the list of files modified, added and deleted will be included in the result. */ - public function setBuildNumberManually($detailed = false) + public function setBuildNumberManually(bool $detailed = false): array { $build = $this->getBuildNumberManually($detailed); @@ -446,19 +422,16 @@ public function setBuildNumberManually($detailed = false) /** * Returns the currently installed system hash. - * @return string */ - public function getHash() + public function getHash(): string { return Parameter::get('system::core.hash', md5('NULL')); } /** * Run migrations on a single module - * @param string $module Module name - * @return self */ - public function migrateModule($module) + public function migrateModule(string $module): static { if (isset($this->notesOutput)) { $this->migrator->setOutput($this->notesOutput); @@ -475,14 +448,12 @@ public function migrateModule($module) /** * Run seeds on a module - * @param string $module Module name - * @return self */ - public function seedModule($module) + public function seedModule(string $module): static { $className = '\\' . $module . '\Database\Seeds\DatabaseSeeder'; if (!class_exists($className)) { - return; + return $this; } $this->out('', true); @@ -503,37 +474,27 @@ public function seedModule($module) /** * Downloads the core from the update server. - * @param string $hash Expected file hash. - * @return void + * @param $hash Expected file hash. */ - public function downloadCore($hash) + public function downloadCore(string $hash): void { $this->requestServerFile('core/get', 'core', $hash, ['type' => 'update']); } /** * Extracts the core after it has been downloaded. - * @return void */ - public function extractCore() + public function extractCore(): void { $filePath = $this->getFilePath('core'); - if (!Zip::extract($filePath, $this->baseDirectory)) { - throw new ApplicationException(Lang::get('system::lang.zip.extract_failed', ['file' => $filePath])); - } - - @unlink($filePath); + $this->extractArchive($filePath, $this->baseDirectory); } /** * Sets the build number and hash - * @param string $hash - * @param string $build - * @param bool $modified - * @return void */ - public function setBuild($build, $hash = null, $modified = false) + public function setBuild(string $build, ?string $hash = null, bool $modified = false): void { $params = [ 'system::core.build' => $build, @@ -553,37 +514,31 @@ public function setBuild($build, $hash = null, $modified = false) /** * Looks up a plugin from the update server. - * @param string $name Plugin name. - * @return array Details about the plugin. */ - public function requestPluginDetails($name) + public function requestPluginDetails(string $name): array { return $this->requestServerData('plugin/detail', ['name' => $name]); } /** * Looks up content for a plugin from the update server. - * @param string $name Plugin name. - * @return array Content for the plugin. */ - public function requestPluginContent($name) + public function requestPluginContent(string $name): array { return $this->requestServerData('plugin/content', ['name' => $name]); } /** * Runs update on a single plugin - * @param string $name Plugin name. - * @return self */ - public function updatePlugin($name) + public function updatePlugin(string $name): static { /* * Update the plugin database and version */ if (!($plugin = $this->pluginManager->findByIdentifier($name))) { $this->write(Error::class, sprintf('Unable to find plugin %s', $name)); - return; + return $this; } $this->out(sprintf('Migrating %s (%s) plugin...', Lang::get($plugin->pluginDetails()['name']), $name)); @@ -599,11 +554,9 @@ public function updatePlugin($name) /** * 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 + * @param $stopOnVersion If this parameter is specified, the process stops once the provided version number is reached */ - public function rollbackPlugin(string $name, string $stopOnVersion = null) + public function rollbackPlugin(string $name, string $stopOnVersion = null): static { /* * Remove the plugin database and version @@ -640,33 +593,27 @@ public function rollbackPlugin(string $name, string $stopOnVersion = null) /** * Downloads a plugin from the update server. - * @param string $name Plugin name. - * @param string $hash Expected file hash. - * @param boolean $installation Indicates whether this is a plugin installation request. - * @return self + * @param $installation Indicates whether this is a plugin installation request. */ - public function downloadPlugin($name, $hash, $installation = false) + public function downloadPlugin(string $name, string $hash, bool $installation = false): static { $fileCode = $name . $hash; $this->requestServerFile('plugin/get', $fileCode, $hash, [ 'name' => $name, 'installation' => $installation ? 1 : 0 ]); + return $this; } /** * Extracts a plugin after it has been downloaded. */ - public function extractPlugin($name, $hash) + public function extractPlugin(string $name, string $hash): void { $fileCode = $name . $hash; $filePath = $this->getFilePath($fileCode); - if (!Zip::extract($filePath, plugins_path())) { - throw new ApplicationException(Lang::get('system::lang.zip.extract_failed', ['file' => $filePath])); - } - - @unlink($filePath); + $this->extractArchive($filePath, plugins_path()); } // @@ -675,51 +622,42 @@ public function extractPlugin($name, $hash) /** * Looks up a theme from the update server. - * @param string $name Theme name. - * @return array Details about the theme. */ - public function requestThemeDetails($name) + public function requestThemeDetails(string $name): array { return $this->requestServerData('theme/detail', ['name' => $name]); } /** * Downloads a theme from the update server. - * @param string $name Theme name. - * @param string $hash Expected file hash. - * @return self */ - public function downloadTheme($name, $hash) + public function downloadTheme(string $name, string $hash): static { $fileCode = $name . $hash; - $this->requestServerFile('theme/get', $fileCode, $hash, ['name' => $name]); + return $this; } /** * Extracts a theme after it has been downloaded. */ - public function extractTheme($name, $hash) + public function extractTheme(string $name, string $hash): void { $fileCode = $name . $hash; $filePath = $this->getFilePath($fileCode); - if (!Zip::extract($filePath, themes_path())) { - throw new ApplicationException(Lang::get('system::lang.zip.extract_failed', ['file' => $filePath])); - } + $this->extractArchive($filePath, themes_path()); if ($this->themeManager) { $this->themeManager->setInstalled($name); } - - @unlink($filePath); } // // Products // - public function requestProductDetails($codes, $type = null) + public function requestProductDetails($codes, $type = null): array { if ($type != 'plugin' && $type != 'theme') { $type = 'plugin'; @@ -771,7 +709,7 @@ public function requestProductDetails($codes, $type = null) /** * Returns popular themes found on the marketplace. */ - public function requestPopularProducts($type = null) + public function requestPopularProducts(string $type = null): array { if ($type != 'plugin' && $type != 'theme') { $type = 'plugin'; @@ -797,7 +735,7 @@ public function requestPopularProducts($type = null) return $data; } - protected function loadProductDetailCache() + protected function loadProductDetailCache(): void { $defaultCache = ['theme' => [], 'plugin' => []]; $cacheKey = 'system-updates-product-details'; @@ -809,7 +747,7 @@ protected function loadProductDetailCache() } } - protected function saveProductDetailCache() + protected function saveProductDetailCache(): void { if ($this->productCache === null) { $this->loadProductDetailCache(); @@ -820,7 +758,7 @@ protected function saveProductDetailCache() Cache::put($cacheKey, base64_encode(serialize($this->productCache)), $expiresAt); } - protected function cacheProductDetail($type, $code, $data) + protected function cacheProductDetail(string $type, string $code, array|int $data): void { if ($this->productCache === null) { $this->loadProductDetailCache(); @@ -836,7 +774,7 @@ protected function cacheProductDetail($type, $code, $data) /** * Returns the latest changelog information. */ - public function requestChangelog() + public function requestChangelog(): array { $build = Parameter::get('system::core.build'); @@ -876,12 +814,9 @@ public function requestChangelog() /** * Writes output to the console using a Laravel CLI View component. - * - * @param \Illuminate\Console\View\Components\Component $component - * @param array $arguments - * @return static + * @param $component Class extending \Illuminate\Console\View\Components\Component to be used to render the message */ - protected function write($component, ...$arguments) + protected function write(string $component, ...$arguments): static { if ($this->notesOutput !== null) { with(new $component($this->notesOutput))->render(...$arguments); @@ -892,12 +827,8 @@ protected function write($component, ...$arguments) /** * Writes output to the console. - * - * @param string $message - * @param bool $newline - * @return static */ - protected function out($message, $newline = false) + protected function out(string $message, bool $newline = false): static { if ($this->notesOutput !== null) { $this->notesOutput->write($message, $newline); @@ -908,10 +839,8 @@ protected function out($message, $newline = false) /** * Sets an output stream for writing notes. - * @param Illuminate\Console\Command $output - * @return self */ - public function setNotesOutput($output) + public function setNotesOutput(OutputStyle $output): static { $this->notesOutput = $output; @@ -924,11 +853,8 @@ public function setNotesOutput($output) /** * Contacts the update server for a response. - * @param string $uri Gateway API URI - * @param array $postData Extra post data - * @return array */ - public function requestServerData($uri, $postData = []) + public function requestServerData(string $uri, array $postData = []): array { $result = Http::post($this->createServerUrl($uri), function ($http) use ($postData) { $this->applyHttpAttributes($http, $postData); @@ -963,18 +889,21 @@ public function requestServerData($uri, $postData = []) throw new ApplicationException(Lang::get('system::lang.server.response_invalid')); } + if (!is_array($resultData)) { + throw new ApplicationException(Lang::get('system::lang.server.response_invalid')); + } + return $resultData; } /** * 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 - * @return void + * @param $uri Gateway API URI + * @param $fileCode A unique code for saving the file. + * @param $expectedHash The expected file hash of the file. + * @param $postData Extra post data */ - public function requestServerFile($uri, $fileCode, $expectedHash, $postData = []) + public function requestServerFile(string $uri, string $fileCode, string $expectedHash, array $postData = []): void { $filePath = $this->getFilePath($fileCode); @@ -995,10 +924,8 @@ public function requestServerFile($uri, $fileCode, $expectedHash, $postData = [] /** * Calculates a file path for a file code - * @param string $fileCode A unique file code - * @return string Full path on the disk */ - protected function getFilePath($fileCode) + protected function getFilePath(string $fileCode): string { $name = md5($fileCode) . '.arc'; return $this->tempDirectory . '/' . $name; @@ -1006,10 +933,8 @@ protected function getFilePath($fileCode) /** * Set the API security for all transmissions. - * @param string $key API Key - * @param string $secret API Secret */ - public function setSecurity($key, $secret) + public function setSecurity(string $key, string $secret): void { $this->key = $key; $this->secret = $secret; @@ -1017,10 +942,8 @@ public function setSecurity($key, $secret) /** * Create a complete gateway server URL from supplied URI - * @param string $uri URI - * @return string URL */ - protected function createServerUrl($uri) + protected function createServerUrl(string $uri): string { $gateway = Config::get('cms.updateServer', 'https://api.wintercms.com/marketplace'); if (substr($gateway, -1) != '/') { @@ -1032,11 +955,8 @@ protected function createServerUrl($uri) /** * Modifies the Network HTTP object with common attributes. - * @param Http $http Network object - * @param array $postData Post data - * @return void */ - protected function applyHttpAttributes($http, $postData) + protected function applyHttpAttributes(NetworkHttp $http, array $postData): void { $postData['protocol_version'] = '1.1'; $postData['client'] = 'october'; @@ -1071,9 +991,8 @@ protected function applyHttpAttributes($http, $postData) /** * Create a nonce based on millisecond time - * @return int */ - protected function createNonce() + protected function createNonce(): int { $mt = explode(' ', microtime()); return $mt[1] . substr($mt[0], 2, 6); @@ -1081,29 +1000,21 @@ protected function createNonce() /** * Create a unique signature for transmission. - * @return string */ - protected function createSignature($data, $secret) + protected function createSignature(array $data, string $secret): string { return base64_encode(hash_hmac('sha512', http_build_query($data, '', '&'), base64_decode($secret), true)); } - /** - * @return string - */ - public function getMigrationTableName() + public function getMigrationTableName(): string { return Config::get('database.migrations', 'migrations'); } /** * Adds a message from a specific migration or seeder. - * - * @param string|object $class - * @param string|array $message - * @return void */ - protected function addMessage($class, $message) + protected function addMessage(string|object $class, string|array $message): void { if (empty($message)) { return; @@ -1125,10 +1036,8 @@ protected function addMessage($class, $message) /** * Prints collated messages from the migrations and seeders - * - * @return void */ - protected function printMessages() + protected function printMessages(): void { if (!count($this->messages)) { return; @@ -1144,4 +1053,48 @@ protected function printMessages() $this->out('', true); } } + + /** + * Extract the provided archive + * + * @throws ApplicationException if the archive failed to extract + */ + public function extractArchive(string $archive, string $destination): void + { + if (!Zip::extract($archive, $destination)) { + throw new ApplicationException(Lang::get('system::lang.zip.extract_failed', ['file' => $archive])); + } + + @unlink($archive); + } + + /** + * Finds all plugins in a given path by looking for valid Plugin.php files + */ + public function findPluginsInPath(string $path): array + { + $pluginFiles = []; + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST + ); + + foreach ($iterator as $file) { + if ($file->isFile() && $file->getFilename() === 'Plugin.php') { + // Attempt to extract the plugin's code + if (!preg_match('/namespace (.+?);/', file_get_contents($file->getRealPath()), $match)) { + continue; + } + + $code = str_replace('\\', '.', $match[1]); + + if (str_contains($code, '.')) { + $pluginFiles[$code] = $file->getPathname(); + } + } + } + + return $pluginFiles; + } } diff --git a/modules/system/console/PluginInstall.php b/modules/system/console/PluginInstall.php index 113fe40268..9a6bbfc44f 100644 --- a/modules/system/console/PluginInstall.php +++ b/modules/system/console/PluginInstall.php @@ -3,6 +3,9 @@ use Winter\Storm\Console\Command; use System\Classes\UpdateManager; use System\Classes\PluginManager; +use Throwable; +use Winter\Storm\Support\Facades\File; +use Winter\Storm\Support\Str; /** * Console command to install a new plugin. @@ -32,13 +35,99 @@ class PluginInstall extends Command /** * Execute the console command. - * @return void + * + * + * Tests: + * plugin.zip - doesn't exist + * plugin.zip - does exist */ - public function handle() + public function handle(): int { $pluginName = $this->argument('plugin'); $manager = UpdateManager::instance()->setNotesOutput($this->output); + if (Str::endsWith($pluginName, '.zip')) { + $packageZip = base_path($pluginName); + $tempPath = temp_path('packages/' . md5($packageZip)); + + // @TODO: Testing only + File::deleteDirectory($tempPath); + File::copy(base_path('plugin-test.zip'), $packageZip); + + // Check if the file exists + if (!File::exists($packageZip)) { + $this->output->writeln(sprintf('File not found: %s', $pluginName)); + return 1; + } + + // Attempt to extract the plugin + $manager->extractArchive($packageZip, $tempPath); + + // Look for plugins to install + $plugins = $manager->findPluginsInPath($tempPath); + + if (empty($plugins)) { + $this->output->writeln(sprintf('No plugins found in: %s', $pluginName)); + return 1; + } + + if (!$this->confirm(sprintf('Detected the following plugins in %s: %s. Would you like to install them all?', $pluginName, Str::join(array_keys($plugins))))) { + return 0; + }; + + $pluginManager = PluginManager::instance(); + + foreach ($plugins as $code => $pluginClassFile) { + // @TODO: Check if plugin is already installed + if ($pluginManager->findByIdentifier($code)) { + if (!$this->confirm(sprintf( + 'Plugin %s is already installed. Would you like to replace the currently installed version with the one found in %s?', + $code, + $pluginName + ))) { + $this->output->writeln(sprintf('Skipping plugin: %s', $code)); + unset($plugins[$code]); + continue; + } + } + + $this->output->writeln(sprintf('Installing plugin: %s', $code)); + $pluginDir = plugins_path(strtolower(str_replace('.', DIRECTORY_SEPARATOR, $code))); + + try { + // Copy the files + File::copyDirectory(pathinfo($pluginClassFile, PATHINFO_DIRNAME), $pluginDir); + + // Load the plugin + $pluginManager->loadPlugins(); + $plugin = $pluginManager->findByIdentifier($code); + $pluginManager->registerPlugin($plugin, $code); + + $pluginManager->clearFlagCache(); + $pluginManager->loadPluginFlags(); + + $pluginsToMigrate[] = $code; + + // Run any pending migrations for the plugin + // @TODO: May have to add some logic here to just run all pending migrations after completing installing all of the plugins + // to account for dependencies. Something else to consider is how we abort a plugin that fails to migrate. + $manager->updatePlugin($code); + + $this->output->writeln(''); + } catch (Throwable $e) { + $this->output->writeln(sprintf('Error installing plugin: %s', $code)); + $this->output->writeln(sprintf('%s', $e->getMessage())); + File::deleteDirectory($pluginDir); + + return 1; + } + } + + $this->output->success(sprintf('Successfully installed %d plugin(s) from %s', count($plugins), $pluginName)); + + return 0; + } + $pluginDetails = $manager->requestPluginDetails($pluginName); $code = array_get($pluginDetails, 'code'); @@ -50,18 +139,16 @@ public function handle() $this->output->writeln(sprintf('Unpacking plugin: %s', $code)); $manager->extractPlugin($code, $hash); - /* - * Make sure plugin is registered - */ + // Make sure plugin is registered $pluginManager = PluginManager::instance(); $pluginManager->loadPlugins(); $plugin = $pluginManager->findByIdentifier($code); $pluginManager->registerPlugin($plugin, $code); - /* - * Migrate plugin - */ + // Migrate plugin $this->output->writeln(sprintf('Migrating plugin...', $code)); $manager->updatePlugin($code); + + return 0; } } diff --git a/modules/system/controllers/Updates.php b/modules/system/controllers/Updates.php index f27e1e4f5d..dd7a68e466 100644 --- a/modules/system/controllers/Updates.php +++ b/modules/system/controllers/Updates.php @@ -1,30 +1,37 @@ -vars['coreBuild'] = Parameter::get('system::core.build'); $this->vars['coreBuildModified'] = Parameter::get('system::core.modified', false); @@ -80,23 +87,23 @@ public function index() $this->vars['projectOwner'] = Parameter::get('system::project.owner'); $this->vars['pluginsActiveCount'] = PluginVersion::applyEnabled()->count(); $this->vars['pluginsCount'] = PluginVersion::count(); - return $this->asExtension('ListController')->index(); + $this->asExtension('ListController')->index(); } /** * Plugin manage controller */ - public function manage() + public function manage(): void { $this->pageTitle = 'system::lang.plugins.manage'; PluginManager::instance()->clearFlagCache(); - return $this->asExtension('ListController')->index(); + $this->asExtension('ListController')->index(); } /** * Install new plugins / themes */ - public function install($tab = null) + public function install($tab = null): ?HttpResponse { if (get('search')) { return Response::make($this->onSearchProducts()); @@ -112,13 +119,16 @@ public function install($tab = null) $this->vars['activeTab'] = $tab ?: 'plugins'; $this->vars['installedPlugins'] = $this->getInstalledPlugins(); $this->vars['installedThemes'] = $this->getInstalledThemes(); + $this->vars['packageUploadWidget'] = $this->getPackageUploadWidget($tab === 'themes' ? 'theme' : 'plugin'); } catch (Exception $ex) { $this->handleError($ex); } + + return null; } - public function details($urlCode = null, $tab = null) + public function details($urlCode = null, $tab = null): void { try { $this->pageTitle = 'system::lang.updates.details_title'; @@ -178,15 +188,15 @@ public function details($urlCode = null, $tab = null) } } - protected function getPluginMarkdownFile($path, $filenames) + protected function getPluginMarkdownFile(string $path, array $filenames): ?string { $contents = null; foreach ($filenames as $file) { - if (!File::exists($path . '/'.$file)) { + if (!File::exists($path . '/' . $file)) { continue; } - $contents = File::get($path . '/'.$file); + $contents = File::get($path . '/' . $file); /* * Parse markdown, clean HTML, remove first H1 tag @@ -199,7 +209,7 @@ protected function getPluginMarkdownFile($path, $filenames) return $contents; } - protected function getWarnings() + protected function getWarnings(): array { $warnings = []; $missingDependencies = PluginManager::instance()->findMissingDependencies(); @@ -242,9 +252,8 @@ protected function getWarnings() * - positive - Default CSS class * * @see Backend\Behaviors\ListController - * @return string */ - public function listInjectRowClass($record, $definition = null) + public function listInjectRowClass($record, $definition = null): string { if ($record->disabledByConfig) { return 'hidden'; @@ -255,7 +264,7 @@ public function listInjectRowClass($record, $definition = null) } if ($definition != 'manage') { - return; + return ''; } if ($record->disabledBySystem) { @@ -272,7 +281,7 @@ public function listInjectRowClass($record, $definition = null) /** * Runs a specific update step. */ - public function onExecuteStep() + public function onExecuteStep(): ?RedirectResponse { /* * Address timeout limits @@ -321,6 +330,8 @@ public function onExecuteStep() Flash::success(Lang::get('system::lang.install.install_success')); return Redirect::refresh(); } + + return null; } // @@ -330,7 +341,7 @@ public function onExecuteStep() /** * Spawns the update checker popup. */ - public function onLoadUpdates() + public function onLoadUpdates(): string { return $this->makePartial('update_form'); } @@ -338,7 +349,7 @@ public function onLoadUpdates() /** * Contacts the update server for a list of necessary updates. */ - public function onCheckForUpdates() + public function onCheckForUpdates(): array { try { $manager = UpdateManager::instance(); @@ -362,10 +373,8 @@ public function onCheckForUpdates() /** * Loops the update list and checks for actionable updates. - * @param array $result - * @return array */ - protected function processImportantUpdates($result) + protected function processImportantUpdates(array $result): array { $hasImportantUpdates = false; @@ -416,10 +425,8 @@ protected function processImportantUpdates($result) /** * Reverses the update lists for the core and all plugins. - * @param array $result - * @return array */ - protected function processUpdateLists($result) + protected function processUpdateLists(array $result): array { if ($core = array_get($result, 'core')) { $result['core']['updates'] = array_reverse(array_get($core, 'updates', []), true); @@ -435,10 +442,9 @@ protected function processUpdateLists($result) /** * Contacts the update server for a list of necessary updates. * - * @param bool $force Whether or not to force the redownload of existing tools - * @return string The rendered "execute" partial + * @param $force Whether or not to force the redownload of existing tools */ - public function onForceUpdate($force = true) + public function onForceUpdate(bool $force = true): string { try { $manager = UpdateManager::instance(); @@ -485,7 +491,7 @@ public function onForceUpdate($force = true) /** * Converts the update data to an actionable array of steps. */ - public function onApplyUpdates() + public function onApplyUpdates(): string { try { /* @@ -577,7 +583,7 @@ public function onApplyUpdates() return $this->makePartial('execute'); } - protected function buildUpdateSteps($core, $plugins, $themes, $isInstallationRequest) + protected function buildUpdateSteps($core, $plugins, $themes, $isInstallationRequest): array { if (!is_array($core)) { $core = [null, null]; @@ -669,7 +675,7 @@ protected function buildUpdateSteps($core, $plugins, $themes, $isInstallationReq /** * Displays the form for entering a Project ID */ - public function onLoadProjectForm() + public function onLoadProjectForm(): string { return $this->makePartial('project_form'); } @@ -677,7 +683,7 @@ public function onLoadProjectForm() /** * Validate the project ID and execute the project installation */ - public function onAttachProject() + public function onAttachProject(): string { try { if (!$projectId = trim(post('project_id'))) { @@ -701,7 +707,7 @@ public function onAttachProject() } } - public function onDetachProject() + public function onDetachProject(): RedirectResponse { Parameter::set([ 'system::project.id' => null, @@ -719,8 +725,10 @@ public function onDetachProject() /** * Displays changelog information + * + * @throws ApplicationException if the changelog could not be fetched from the server */ - public function onLoadChangelog() + public function onLoadChangelog(): string { try { $fetchedContent = UpdateManager::instance()->requestChangelog(); @@ -744,10 +752,117 @@ public function onLoadChangelog() // Plugin management // + protected ?Form $packageUploadWidget = null; + + /** + * Get the form widget for the import popup. + */ + protected function getPackageUploadWidget(string $type = 'plugin'): Form + { + $type = post('type', $type); + + if (!in_array($type, ['plugin', 'theme'])) { + throw new ApplicationException('Invalid package type'); + } + + if ($this->packageUploadWidget !== null) { + return $this->packageUploadWidget; + } + + $config = $this->makeConfig("form.{$type}_upload.yaml"); + $config->model = new class extends Model { + public $attachOne = [ + 'uploaded_package' => [\System\Models\File::class, 'public' => false], + ]; + }; + $widget = $this->makeWidget(Form::class, $config); + $widget->bindToController(); + + return $this->packageUploadWidget = $widget; + } + + /** + * Displays the plugin uploader form + */ + public function onLoadPluginUploader(): string + { + $this->vars['packageUploadWidget'] = $this->getPackageUploadWidget('plugin'); + return $this->makePartial('popup_upload_plugin'); + } + + /** + * Installs an uploaded plugin + */ + public function onInstallUploadedPlugin(): string + { + try { + // Get the deferred binding record for the uploaded file + $widget = $this->getPackageUploadWidget(); + $class = Str::before(get_class($widget->model), chr(0)); + $deferred = DeferredBinding::query() + ->where('master_type', 'LIKE', str_replace('\\', '\\\\', $class) . '%') + ->where('master_field', 'uploaded_package') + ->where('session_key', $widget->getSessionKey()) + ->first(); + + // Attempt to get the file from the deferred binding + if (!$deferred || !$deferred->slave) { + throw new ApplicationException(Lang::get('system::lang.server.response_invalid')); + } + $file = $deferred->slave; + + /** + * @TODO: + * - Process the uploaded file to identify the plugins to install + * - (optional) require confirmation to install each detected plugin + * - Install the identified plugins + * - Ensure that deferred binding records and uploaded files are removed post processing or on failure + */ + + $manager = UpdateManager::instance(); + + $result = $manager->installUploadedPlugin(); + + if (!isset($result['code']) || !isset($result['hash'])) { + throw new ApplicationException(Lang::get('system::lang.server.response_invalid')); + } + + $name = $result['code']; + $hash = $result['hash']; + $plugins = [$name => $hash]; + $plugins = $this->appendRequiredPlugins($plugins, $result); + + /* + * Update steps + */ + $updateSteps = $this->buildUpdateSteps(null, $plugins, [], true); + + /* + * Finish up + */ + $updateSteps[] = [ + 'code' => 'completeInstall', + 'label' => Lang::get('system::lang.install.install_completing'), + ]; + + $this->vars['updateSteps'] = $updateSteps; + + return $this->makePartial('execute'); + } + catch (Exception $ex) { + // @TODO: Remove this, temporary debugging + throw $ex; + $this->handleError($ex); + return $this->makePartial('plugin_uploader'); + } + } + /** * Validate the plugin code and execute the plugin installation + * + * @throws ApplicationException If validation fails or the plugin cannot be installed */ - public function onInstallPlugin() + public function onInstallPlugin(): string { try { if (!$code = trim(post('code'))) { @@ -791,9 +906,8 @@ public function onInstallPlugin() /** * Rollback and remove a single plugin from the system. - * @return void */ - public function onRemovePlugin() + public function onRemovePlugin(): RedirectResponse { if ($pluginCode = post('code')) { PluginManager::instance()->deletePlugin($pluginCode); @@ -805,9 +919,8 @@ public function onRemovePlugin() /** * Perform a bulk action on the provided plugins - * @return void */ - public function onBulkAction() + public function onBulkAction(): RedirectResponse { if (($bulkAction = post('action')) && ($checkedIds = post('checked')) && @@ -912,9 +1025,8 @@ public function onInstallTheme() /** * Deletes a single theme from the system. - * @return void */ - public function onRemoveTheme() + public function onRemoveTheme(): RedirectResponse { if ($themeCode = post('code')) { ThemeManager::instance()->deleteTheme($themeCode); @@ -929,7 +1041,7 @@ public function onRemoveTheme() // Product install // - public function onSearchProducts() + public function onSearchProducts(): array { $searchType = get('search', 'plugins'); $serverUri = $searchType == 'plugins' ? 'plugin/search' : 'theme/search'; @@ -938,7 +1050,7 @@ public function onSearchProducts() return $manager->requestServerData($serverUri, ['query' => get('query')]); } - public function onGetPopularPlugins() + public function onGetPopularPlugins(): array { $installed = $this->getInstalledPlugins(); $popular = UpdateManager::instance()->requestPopularProducts('plugin'); @@ -947,7 +1059,7 @@ public function onGetPopularPlugins() return ['result' => $popular]; } - public function onGetPopularThemes() + public function onGetPopularThemes(): array { $installed = $this->getInstalledThemes(); $popular = UpdateManager::instance()->requestPopularProducts('theme'); @@ -956,14 +1068,14 @@ public function onGetPopularThemes() return ['result' => $popular]; } - protected function getInstalledPlugins() + protected function getInstalledPlugins(): array { $installed = PluginVersion::lists('code'); $manager = UpdateManager::instance(); return $manager->requestProductDetails($installed, 'plugin'); } - protected function getInstalledThemes() + protected function getInstalledThemes(): array { $history = Parameter::get('system::theme.history', []); $manager = UpdateManager::instance(); @@ -983,7 +1095,7 @@ protected function getInstalledThemes() /* * Remove installed products from the collection */ - protected function filterPopularProducts($popular, $installed) + protected function filterPopularProducts($popular, $installed): array { $installedArray = []; foreach ($installed as $product) { @@ -1007,7 +1119,7 @@ protected function filterPopularProducts($popular, $installed) /** * Encode HTML safe product code, this is to prevent issues with array_get(). */ - protected function encodeCode($code) + protected function encodeCode(string $code): string { return str_replace('.', ':', $code); } @@ -1015,18 +1127,15 @@ protected function encodeCode($code) /** * Decode HTML safe product code. */ - protected function decodeCode($code) + protected function decodeCode(string $code): string { return str_replace(':', '.', $code); } /** * Adds require plugin codes to the collection based on a result. - * @param array $plugins - * @param array $result - * @return array */ - protected function appendRequiredPlugins(array $plugins, array $result) + protected function appendRequiredPlugins(array $plugins, array $result): array { foreach ((array) array_get($result, 'require') as $plugin) { if ( diff --git a/modules/system/controllers/updates/_install_plugins.php b/modules/system/controllers/updates/_install_plugins.php index 461ff78d3f..427f1ff002 100644 --- a/modules/system/controllers/updates/_install_plugins.php +++ b/modules/system/controllers/updates/_install_plugins.php @@ -1,23 +1,38 @@
-
-