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); } } 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' => serialize($versions), 'themes' => serialize($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 = $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 */ while (true) { if (isset($this->notesOutput)) { $this->migrator->setOutput($this->notesOutput); } $rolledBack = $this->migrator->rollback($paths, ['pretend' => false]); if (count($rolledBack) == 0) { break; } } Schema::dropIfExists($this->getMigrationTableName()); return $this; } /** * Asks the gateway for the lastest build number and stores it. * @return void */ public function setBuildNumberManually() { $postData = []; if (Config::get('cms.edgeUpdates', false)) { $postData['edge'] = 1; } $result = $this->requestServerData('ping', $postData); $build = (int) array_get($result, 'pong', 420); $this->setBuild($build); 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) { $this->note($module); if (isset($this->notesOutput)) { $this->migrator->setOutput($this->notesOutput); } $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); $seeder->run(); $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 * @return void */ public function setBuild($build, $hash = null) { $params = [ 'system::core.build' => $build ]; 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->resetNotes()->setNotesOutput($this->notesOutput); if ($this->versionManager->updatePlugin($plugin) !== false) { foreach ($this->versionManager->getNotes() as $note) { $this->note($note); } } return $this; } /** * Removes an existing plugin * @param string $name Plugin name. * @return self */ public function rollbackPlugin($name) { /* * 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 ($this->versionManager->removePlugin($plugin)) { $this->note('Rolled back: ' . $name); 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); if (!Zip::extract($filePath, $this->baseDirectory . '/plugins/')) { 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); if (!Zip::extract($filePath, $this->baseDirectory . '/themes/')) { 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); } else { $this->notes[] = $message; } return $this; } /** * Get the notes for the last operation. * @return array */ public function getNotes() { return $this->notes; } /** * Resets the notes store. * @return self */ public function resetNotes() { $this->notesOutput = null; $this->notes = []; 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')); } 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 ($result->code != 200) { throw new ApplicationException(File::get($filePath)); } if (md5_file($filePath) != $expectedHash) { @unlink($filePath); throw new ApplicationException(Lang::get('system::lang.server.file_corrupt')); } } /** * 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', 'http://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.1'; $postData['client'] = 'october'; $postData['server'] = base64_encode(serialize([ 'php' => PHP_VERSION, 'url' => Url::to('/'), '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'); } }