pluginManager = PluginManager::instance(); $this->themeManager = class_exists(ThemeManager::class) ? ThemeManager::instance() : null; $this->versionManager = VersionManager::instance(); $this->tempDirectory = temp_path(); $this->baseDirectory = base_path(); $this->disableCoreUpdates = Config::get('cms.disableCoreUpdates', false); $this->bindContainerObjects(); /* * Ensure temp directory exists */ if (!File::isDirectory($this->tempDirectory)) { File::makeDirectory($this->tempDirectory, 0777, true); } } /** * 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->migrator = App::make('migrator'); $this->repository = App::make('migration.repository'); } /** * Creates the migration table and updates * @return self */ public function update() { $firstUp = !Schema::hasTable($this->getMigrationTableName()); if ($firstUp) { $this->repository->createRepository(); $this->note('Migration table created'); } /* * Update modules */ $modules = Config::get('cms.loadModules', []); foreach ($modules as $module) { $this->migrateModule($module); } /* * Update plugins */ $plugins = $this->pluginManager->getPlugins(); foreach ($plugins as $code => $plugin) { $this->updatePlugin($code); } Parameter::set('system::update.count', 0); CacheHelper::clear(); /* * Seed modules */ if ($firstUp) { $modules = Config::get('cms.loadModules', []); foreach ($modules as $module) { $this->seedModule($module); } } // Print messages returned by migrations / seeders $this->printMessages(); return $this; } /** * 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. */ public function check($force = false) { /* * Already know about updates, never retry. */ $oldCount = Parameter::get('system::update.count'); if ($oldCount > 0) { return $oldCount; } /* * Retry period not passed, skipping. */ if (!$force && ($retryTimestamp = Parameter::get('system::update.retry')) && Carbon::createFromTimeStamp($retryTimestamp)->isFuture() ) { return $oldCount; } try { $result = $this->requestUpdateList(); $newCount = array_get($result, 'update', 0); } catch (Exception $ex) { $newCount = 0; } /* * Remember update count, set retry date */ Parameter::set('system::update.count', $newCount); Parameter::set('system::update.retry', Carbon::now()->addHours(24)->timestamp); return $newCount; } /** * Requests an update list used for checking for new updates. * @param boolean $force Request application and plugins hash list regardless of version. * @return array */ public function requestUpdateList($force = false) { $installed = PluginVersion::all(); $versions = $installed->lists('version', 'code'); $names = $installed->lists('name', 'code'); $icons = $installed->lists('icon', 'code'); $frozen = $installed->lists('is_frozen', 'code'); $updatable = $installed->lists('is_updatable', 'code'); $build = Parameter::get('system::core.build'); $themes = []; if ($this->themeManager) { $themes = array_keys($this->themeManager->getInstalled()); } $params = [ 'core' => $this->getHash(), 'plugins' => base64_encode(json_encode($versions)), 'themes' => base64_encode(json_encode($themes)), 'build' => $build, 'force' => $force ]; $result = $this->requestServerData('core/update', $params); $updateCount = (int) array_get($result, 'update', 0); /* * Inject known core build */ if ($core = array_get($result, 'core')) { $core['old_build'] = Parameter::get('system::core.build'); $result['core'] = $core; } /* * Inject the application's known plugin name and version */ $plugins = []; foreach (array_get($result, 'plugins', []) as $code => $info) { $info['name'] = $names[$code] ?? $code; $info['old_version'] = $versions[$code] ?? false; $info['icon'] = $icons[$code] ?? false; /* * If a plugin has updates frozen, or cannot be updated, * do not add to the list and discount an update unit. */ if ( (isset($frozen[$code]) && $frozen[$code]) || (isset($updatable[$code]) && !$updatable[$code]) ) { $updateCount = max(0, --$updateCount); } else { $plugins[$code] = $info; } } $result['plugins'] = $plugins; /* * Strip out themes that have been installed before */ if ($this->themeManager) { $themes = []; foreach (array_get($result, 'themes', []) as $code => $info) { if (!$this->themeManager->isInstalled($code)) { $themes[$code] = $info; } } $result['themes'] = $themes; } /* * If there is a core update and core updates are disabled, * remove the entry and discount an update unit. */ if (array_get($result, 'core') && $this->disableCoreUpdates) { $updateCount = max(0, --$updateCount); unset($result['core']); } /* * Recalculate the update counter */ $updateCount += count($themes); $result['hasUpdates'] = $updateCount > 0; $result['update'] = $updateCount; Parameter::set('system::update.count', $updateCount); return $result; } /** * Requests details about a project based on its identifier. * @param string $projectId * @return array */ public function requestProjectDetails($projectId) { return $this->requestServerData('project/detail', ['id' => $projectId]); } /** * Roll back all modules and plugins. * @return self */ public function uninstall() { /* * Rollback plugins */ $plugins = array_reverse($this->pluginManager->getPlugins()); foreach ($plugins as $name => $plugin) { $this->rollbackPlugin($name); } /* * Register module migration files */ $paths = []; $modules = Config::get('cms.loadModules', []); foreach ($modules as $module) { $paths[] = $path = base_path() . '/modules/' . strtolower($module) . '/database/migrations'; } /* * Rollback modules */ if (isset($this->notesOutput)) { $this->migrator->setOutput($this->notesOutput); } while (true) { $rolledBack = $this->migrator->rollback($paths, ['pretend' => false]); if (count($rolledBack) == 0) { break; } } Schema::dropIfExists($this->getMigrationTableName()); return $this; } /** * Determines build number from source manifest. * * This will return an array with the following information: * - `build`: The build number we determined was most likely the build installed. * - `modified`: Whether we detected any modifications between the installed build and the manifest. * - `confident`: Whether we are at least 60% sure that this is the installed build. More modifications to * 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 */ public function getBuildNumberManually($detailed = false) { $source = new SourceManifest(); $manifest = new FileManifest(null, null, true); // Find build by comparing with source manifest return $source->compare($manifest, $detailed); } /** * 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 */ public function setBuildNumberManually($detailed = false) { $build = $this->getBuildNumberManually($detailed); if ($build['confident']) { $this->setBuild($build['build'], null, $build['modified']); } return $build; } // // Modules // /** * Returns the currently installed system hash. * @return string */ public function getHash() { 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) { if (isset($this->notesOutput)) { $this->migrator->setOutput($this->notesOutput); } $this->note($module); $this->migrator->run(base_path() . '/modules/'.strtolower($module).'/database/migrations'); return $this; } /** * Run seeds on a module * @param string $module Module name * @return self */ public function seedModule($module) { $className = '\\' . $module . '\Database\Seeds\DatabaseSeeder'; if (!class_exists($className)) { return; } $seeder = App::make($className); $return = $seeder->run(); if (isset($return) && (is_string($return) || is_array($return))) { $this->addMessage($className, $return); } $this->note(sprintf('Seeded %s ', $module)); return $this; } /** * Downloads the core from the update server. * @param string $hash Expected file hash. * @return void */ public function downloadCore($hash) { $this->requestServerFile('core/get', 'core', $hash, ['type' => 'update']); } /** * Extracts the core after it has been downloaded. * @return void */ public function extractCore() { $filePath = $this->getFilePath('core'); if (!Zip::extract($filePath, $this->baseDirectory)) { throw new ApplicationException(Lang::get('system::lang.zip.extract_failed', ['file' => $filePath])); } @unlink($filePath); } /** * 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) { $params = [ 'system::core.build' => $build, 'system::core.modified' => $modified, ]; if ($hash) { $params['system::core.hash'] = $hash; } Parameter::set($params); } // // Plugins // /** * Looks up a plugin from the update server. * @param string $name Plugin name. * @return array Details about the plugin. */ public function requestPluginDetails($name) { 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) { return $this->requestServerData('plugin/content', ['name' => $name]); } /** * Runs update on a single plugin * @param string $name Plugin name. * @return self */ public function updatePlugin($name) { /* * Update the plugin database and version */ if (!($plugin = $this->pluginManager->findByIdentifier($name))) { $this->note('Unable to find: ' . $name); return; } $this->note($name); $this->versionManager->setNotesOutput($this->notesOutput); $this->versionManager->updatePlugin($plugin); return $this; } /** * 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(string $name, string $stopOnVersion = null) { /* * Remove the plugin database and version */ if (!($plugin = $this->pluginManager->findByIdentifier($name)) && $this->versionManager->purgePlugin($name) ) { $this->note('Purged from database: ' . $name); return $this; } 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; } $this->note('Unable to find: ' . $name); return $this; } /** * 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 */ public function downloadPlugin($name, $hash, $installation = false) { $fileCode = $name . $hash; $this->requestServerFile('plugin/get', $fileCode, $hash, [ 'name' => $name, 'installation' => $installation ? 1 : 0 ]); } /** * Extracts a plugin after it has been downloaded. */ public function extractPlugin($name, $hash) { $fileCode = $name . $hash; $filePath = $this->getFilePath($fileCode); $innerPath = str_replace('.', '/', strtolower($name)); if (!Zip::extract($filePath, plugins_path($innerPath))) { throw new ApplicationException(Lang::get('system::lang.zip.extract_failed', ['file' => $filePath])); } @unlink($filePath); } // // Themes // /** * Looks up a theme from the update server. * @param string $name Theme name. * @return array Details about the theme. */ public function requestThemeDetails($name) { 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) { $fileCode = $name . $hash; $this->requestServerFile('theme/get', $fileCode, $hash, ['name' => $name]); } /** * Extracts a theme after it has been downloaded. */ public function extractTheme($name, $hash) { $fileCode = $name . $hash; $filePath = $this->getFilePath($fileCode); $innerPath = str_replace('.', '-', strtolower($name)); if (!Zip::extract($filePath, themes_path($innerPath))) { throw new ApplicationException(Lang::get('system::lang.zip.extract_failed', ['file' => $filePath])); } if ($this->themeManager) { $this->themeManager->setInstalled($name); } @unlink($filePath); } // // Products // public function requestProductDetails($codes, $type = null) { if ($type != 'plugin' && $type != 'theme') { $type = 'plugin'; } $codes = (array) $codes; $this->loadProductDetailCache(); /* * New products requested */ $newCodes = array_diff($codes, array_keys($this->productCache[$type])); if (count($newCodes)) { $dataCodes = []; $data = $this->requestServerData($type . '/details', ['names' => $newCodes]); foreach ($data as $product) { $code = array_get($product, 'code', -1); $this->cacheProductDetail($type, $code, $product); $dataCodes[] = $code; } /* * Cache unknown products */ $unknownCodes = array_diff($newCodes, $dataCodes); foreach ($unknownCodes as $code) { $this->cacheProductDetail($type, $code, -1); } $this->saveProductDetailCache(); } /* * Build details from cache */ $result = []; $requestedDetails = array_intersect_key($this->productCache[$type], array_flip($codes)); foreach ($requestedDetails as $detail) { if ($detail === -1) { continue; } $result[] = $detail; } return $result; } /** * Returns popular themes found on the marketplace. */ public function requestPopularProducts($type = null) { if ($type != 'plugin' && $type != 'theme') { $type = 'plugin'; } $cacheKey = 'system-updates-popular-' . $type; if (Cache::has($cacheKey)) { return @unserialize(@base64_decode(Cache::get($cacheKey))) ?: []; } $data = $this->requestServerData($type . '/popular'); $expiresAt = now()->addMinutes(60); Cache::put($cacheKey, base64_encode(serialize($data)), $expiresAt); foreach ($data as $product) { $code = array_get($product, 'code', -1); $this->cacheProductDetail($type, $code, $product); } $this->saveProductDetailCache(); return $data; } protected function loadProductDetailCache() { $defaultCache = ['theme' => [], 'plugin' => []]; $cacheKey = 'system-updates-product-details'; if (Cache::has($cacheKey)) { $this->productCache = @unserialize(@base64_decode(Cache::get($cacheKey))) ?: $defaultCache; } else { $this->productCache = $defaultCache; } } protected function saveProductDetailCache() { if ($this->productCache === null) { $this->loadProductDetailCache(); } $cacheKey = 'system-updates-product-details'; $expiresAt = Carbon::now()->addDays(2); Cache::put($cacheKey, base64_encode(serialize($this->productCache)), $expiresAt); } protected function cacheProductDetail($type, $code, $data) { if ($this->productCache === null) { $this->loadProductDetailCache(); } $this->productCache[$type][$code] = $data; } // // Changelog // /** * Returns the latest changelog information. */ public function requestChangelog() { $result = Http::get('https://octobercms.com/changelog?json'); if ($result->code == 404) { throw new ApplicationException(Lang::get('system::lang.server.response_empty')); } if ($result->code != 200) { throw new ApplicationException( strlen($result->body) ? $result->body : Lang::get('system::lang.server.response_empty') ); } try { $resultData = json_decode($result->body, true); } catch (Exception $ex) { throw new ApplicationException(Lang::get('system::lang.server.response_invalid')); } return $resultData; } // // Notes // /** * Raise a note event for the migrator. * @param string $message * @return self */ protected function note($message) { if ($this->notesOutput !== null) { $this->notesOutput->writeln($message); } return $this; } /** * Sets an output stream for writing notes. * @param Illuminate\Console\Command $output * @return self */ public function setNotesOutput($output) { $this->notesOutput = $output; return $this; } // // Gateway access // /** * 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 = []) { $result = Http::post($this->createServerUrl($uri), function ($http) use ($postData) { $this->applyHttpAttributes($http, $postData); }); if ($result->code == 404) { throw new ApplicationException(Lang::get('system::lang.server.response_not_found')); } if ($result->code != 200) { throw new ApplicationException( strlen($result->body) ? $result->body : Lang::get('system::lang.server.response_empty') ); } $resultData = false; try { $resultData = @json_decode($result->body, true); } catch (Exception $ex) { throw new ApplicationException(Lang::get('system::lang.server.response_invalid')); } if ($resultData === false || (is_string($resultData) && !strlen($resultData))) { throw new ApplicationException(Lang::get('system::lang.server.response_invalid')); } if (!$this->validateServerSignature($resultData, $result->headers['Rest-Sign'] ?? '')) { throw new ApplicationException(Lang::get('system::lang.server.response_invalid') . ' (Bad signature)'); } 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 */ public function requestServerFile($uri, $fileCode, $expectedHash, $postData = []) { $filePath = $this->getFilePath($fileCode); $result = Http::post($this->createServerUrl($uri), function ($http) use ($postData, $filePath) { $this->applyHttpAttributes($http, $postData); $http->toFile($filePath); }); if (in_array($result->code, [301, 302])) { if ($redirectUrl = array_get($result->info, 'redirect_url')) { $result = Http::get($redirectUrl, function ($http) use ($postData, $filePath) { $http->toFile($filePath); }); } } if ($result->code != 200) { throw new ApplicationException(File::get($filePath)); } } /** * 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) { $name = md5($fileCode) . '.arc'; return $this->tempDirectory . '/' . $name; } /** * Set the API security for all transmissions. * @param string $key API Key * @param string $secret API Secret */ public function setSecurity($key, $secret) { $this->key = $key; $this->secret = $secret; } /** * Create a complete gateway server URL from supplied URI * @param string $uri URI * @return string URL */ protected function createServerUrl($uri) { $gateway = Config::get('cms.updateServer', 'https://gateway.octobercms.com/api'); if (substr($gateway, -1) != '/') { $gateway .= '/'; } return $gateway . $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) { $postData['protocol_version'] = '1.3'; $postData['client'] = 'October CMS'; $postData['server'] = base64_encode(json_encode([ 'php' => PHP_VERSION, 'url' => Url::to('/'), 'ip' => Request::ip(), 'since' => PluginVersion::orderBy('created_at')->value('created_at') ])); if ($projectId = Parameter::get('system::project.id')) { $postData['project'] = $projectId; } if (Config::get('cms.edgeUpdates', false)) { $postData['edge'] = 1; } if ($this->key && $this->secret) { $postData['nonce'] = $this->createNonce(); $http->header('Rest-Key', $this->key); $http->header('Rest-Sign', $this->createSignature($postData, $this->secret)); } if ($credentials = Config::get('cms.updateAuth')) { $http->auth($credentials); } $http->noRedirect(); $http->data($postData); } /** * Create a nonce based on millisecond time * @return int */ protected function createNonce() { $mt = explode(' ', microtime()); return $mt[1] . substr($mt[0], 2, 6); } /** * Create a unique signature for transmission. * @return string */ protected function createSignature($data, $secret) { return base64_encode(hash_hmac('sha512', http_build_query($data, '', '&'), base64_decode($secret), true)); } /** * @return string */ public function getMigrationTableName() { 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) { if (empty($message)) { return; } if (is_object($class)) { $class = get_class($class); } if (!isset($this->messages[$class])) { $this->messages[$class] = []; } if (is_string($message)) { $this->messages[$class][] = $message; } elseif (is_array($message)) { array_merge($this->messages[$class], $message); } } /** * Prints collated messages from the migrations and seeders * * @return void */ protected function printMessages() { if (!count($this->messages)) { return; } // Add a line break $this->note(''); foreach ($this->messages as $class => $messages) { $this->note(sprintf('%s reported:', $class)); foreach ($messages as $message) { $this->note(' - ' . (string) $message); } } } /** * validateServerSignature checks the server has provided a valid signature * * @return bool */ protected function validateServerSignature($data, $signature) { if (!$signature) { return false; } $signature = base64_decode($signature); $pubKey = '-----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAt+KwvTXqC8Mz9vV4KIvX 3y+aZusrlg26jdbNVUuhXNFbt1VisjJydHW2+WGsiEHSy2s61ZAV2dICR6f3huSw jY/MH9j23Oo/u61CBpvIS3Q8uC+TLtJl4/F9eqlnzocfMoKe8NmcBbUR3TKQoIok xbSMl6jiE2k5TJdzhHUxjZRIeeLDLMKYX6xt37LdhuM8zO6sXQmCGg4J6LmHTJph 96H11gBvcFSFJSmIiDykJOELZl/aVcY1g3YgpL0mw5Bw1VTmKaRdz1eBi9DmKrKX UijG4gD8eLRV/FS/sZCFNR/evbQXvTBxO0TOIVi85PlQEcMl4SBj0CoTyNbcAGtz 4wIDAQAB -----END PUBLIC KEY-----'; $pubKey = Config::get('system.update_gateway_key', $pubKey); $data = base64_encode(json_encode($data)); return openssl_verify($data, $signature, $pubKey) === 1; } }