From 577c2be622b10eadeea0418d73b953a5ca7ad720 Mon Sep 17 00:00:00 2001 From: Dmitriy Simushev Date: Tue, 26 May 2015 13:19:13 +0000 Subject: [PATCH] Create a way to automatically get info about available updates --- README.md | 2 +- src/mibew/README.txt | 2 +- src/mibew/configs/database_schema.yml | 16 + .../Mibew/Maintenance/AvailableUpdate.php | 253 ++++++++++++++ .../classes/Mibew/Maintenance/CronWorker.php | 27 +- .../classes/Mibew/Maintenance/Installer.php | 2 +- .../Mibew/Maintenance/UpdateChecker.php | 328 ++++++++++++++++++ .../classes/Mibew/Maintenance/Updater.php | 12 +- 8 files changed, 635 insertions(+), 7 deletions(-) create mode 100644 src/mibew/libs/classes/Mibew/Maintenance/AvailableUpdate.php create mode 100644 src/mibew/libs/classes/Mibew/Maintenance/UpdateChecker.php diff --git a/README.md b/README.md index dee16a2d..2a8964f9 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ This repository contains the core of Mibew Messenger application. ## Server requirements 1. A webserver or web hosting account running on any major Operating System -2. PHP (5.3.3 and above) with PDO, pdo_mysql and gd extensions +2. PHP (5.3.3 and above) with PDO, pdo_mysql, cURL and gd extensions 3. MySQL 5.0 and above ## Build from sources diff --git a/src/mibew/README.txt b/src/mibew/README.txt index 0452dd06..ed0f5292 100644 --- a/src/mibew/README.txt +++ b/src/mibew/README.txt @@ -6,7 +6,7 @@ REQUIREMENTS * Apache web server 1.3.34 or above with the ability to use local .htaccess files (mod_rewrite module is optional, but recommended) * MySQL database 5.0 or above - * PHP 5.3.3 or above with PDO, pdo_mysql and gd extensions + * PHP 5.3.3 or above with PDO, pdo_mysql, cURL and gd extensions INSTALLATION diff --git a/src/mibew/configs/database_schema.yml b/src/mibew/configs/database_schema.yml index 160c1fea..98d9ef7b 100644 --- a/src/mibew/configs/database_schema.yml +++ b/src/mibew/configs/database_schema.yml @@ -316,3 +316,19 @@ plugin: enabled: "tinyint NOT NULL DEFAULT 0" unique_keys: name: [name] + +# Contains info about all available updates +available_update: + field: + # Artificial ID + id: "INT NOT NULL auto_increment PRIMARY KEY" + # Can be either "core" or fully qualified plugin's name + target: "varchar(255) NOT NULL" + # The latest available version of the plugin + version: "varchar(255) NOT NULL" + # A URL where the new version can be downloaded + url: "text" + # Description of the update + description: "text" + unique_keys: + target: [target] diff --git a/src/mibew/libs/classes/Mibew/Maintenance/AvailableUpdate.php b/src/mibew/libs/classes/Mibew/Maintenance/AvailableUpdate.php new file mode 100644 index 00000000..27789251 --- /dev/null +++ b/src/mibew/libs/classes/Mibew/Maintenance/AvailableUpdate.php @@ -0,0 +1,253 @@ +query( + "SELECT * FROM {available_update} WHERE id = :id", + array(':id' => $id), + array('return_rows' => Database::RETURN_ONE_ROW) + ); + + // There is no update with such id in database + if (!$info) { + return false; + } + + // Create and populate update object + $update = new self(); + $update->populateFromDbFields($info); + + return $update; + } + + /** + * Loads update by its target. + * + * @param string $target Target of the update to load. + * @return boolean|AvailableUpdate Returns an AvailableUpdate instance or + * boolean false on failure. + */ + public static function loadByTarget($target) + { + // Check the target + if (empty($target)) { + return false; + } + + // Load update info + $info = Database::getInstance()->query( + "SELECT * FROM {available_update} WHERE target = :target", + array(':target' => $target), + array('return_rows' => Database::RETURN_ONE_ROW) + ); + + // There is no update with such target in database + if (!$info) { + return false; + } + + // Create and populate update object + $update = new self(); + $update->populateFromDbFields($info); + + return $update; + } + + /** + * Loads available updates. + * + * @return array List of AvailableUpdate instances. + * + * @throws \RuntimeException If something went wrong and the list could not + * be loaded. + */ + public static function all() + { + $rows = Database::getInstance()->query( + "SELECT * FROM {available_updates}", + null, + array('return_rows' => Database::RETURN_ALL_ROWS) + ); + + if ($rows === false) { + throw new \RuntimeException('List of available updates cannot be retrieved.'); + } + + $updates = array(); + foreach ($rows as $item) { + $update = new self(); + $update->populateFromDbFields($item); + $updates[] = $update; + } + + return $updates; + } + + /** + * Class constructor. + */ + public function __construct() + { + // Set default values + $this->id = false; + $this->target = null; + $this->version = null; + $this->url = ''; + $this->description = ''; + } + + /** + * Remove record about available update from the database. + */ + public function delete() + { + if (!$this->id) { + throw new \RuntimeException('You cannot delete an update without id'); + } + + Database::getInstance()->query( + "DELETE FROM {available_update} WHERE id = :id LIMIT 1", + array(':id' => $this->id) + ); + } + + /** + * Save the update to the database. + */ + public function save() + { + $db = Database::getInstance(); + + if (!$this->target) { + throw new \RuntimeException('Update\'s target was not set'); + } + + if (!$this->url) { + throw new \RuntimeException('Update\'s URL was not set'); + } + + if (!$this->version) { + throw new \RuntimeException('Update\'s version was not set'); + } + + if (!$this->id) { + // This update is new. + $db->query( + ("INSERT INTO {available_update} (target, version, url, description) " + . "VALUES (:target, :version, :url, :description)"), + array( + ':target' => $this->target, + ':version' => $this->version, + ':url' => $this->url, + ':description' => $this->description, + ) + ); + $this->id = $db->insertedId(); + } else { + // Update existing update + $db->query( + ("UPDATE {available_update} SET target = :target, url = :url, " + . "version = :version, description = :description " + . "WHERE id = :id"), + array( + ':id' => $this->id, + ':target' => $this->target, + ':version' => $this->version, + ':url' => $this->url, + ':description' => $this->description, + ) + ); + } + } + + /** + * Sets update's fields according to the fields from Database. + * + * @param array $db_fields Associative array of database fields which keys + * are fields names and the values are fields values. + */ + protected function populateFromDbFields($db_fields) + { + $this->id = $db_fields['id']; + $this->target = $db_fields['target']; + $this->version = $db_fields['version']; + $this->url = $db_fields['url']; + $this->description = $db_fields['description']; + } +} diff --git a/src/mibew/libs/classes/Mibew/Maintenance/CronWorker.php b/src/mibew/libs/classes/Mibew/Maintenance/CronWorker.php index 423ddf67..55a6b405 100644 --- a/src/mibew/libs/classes/Mibew/Maintenance/CronWorker.php +++ b/src/mibew/libs/classes/Mibew/Maintenance/CronWorker.php @@ -36,6 +36,13 @@ class CronWorker */ protected $cache = null; + /** + * An instance of update checker. + * + * @var UpdateChecker|null + */ + protected $updateChecker = null; + /** * List of errors. * @@ -54,10 +61,14 @@ class CronWorker * Class constructor. * * @param PoolInterface $cache An instance of cache pool. + * @param UpdateChecker $update_checker An instance of update checker. */ - public function __construct(PoolInterface $cache) + public function __construct(PoolInterface $cache, UpdateChecker $update_checker = null) { $this->cache = $cache; + $this->updateChecker = is_null($update_checker) + ? new UpdateChecker() + : $update_checker; } /** @@ -71,6 +82,9 @@ class CronWorker try { set_time_limit(0); + // Update time of last cron run + Settings::set('_last_cron_run', time()); + // Remove stale cached items $this->cache->purge(); @@ -83,8 +97,15 @@ class CronWorker $dispatcher = EventDispatcher::getInstance(); $dispatcher->triggerEvent(Events::CRON_RUN); - // Update time of last cron run - Settings::set('_last_cron_run', time()); + // Run the update checker + if (!$this->updateChecker->run()) { + $this->errors = array_merge( + $this->errors, + $this->updateChecker->getErrors() + ); + + return false; + } } catch (\Exception $e) { $this->log[] = $e->getMessage(); diff --git a/src/mibew/libs/classes/Mibew/Maintenance/Installer.php b/src/mibew/libs/classes/Mibew/Maintenance/Installer.php index 773bbfd7..cb0d8625 100644 --- a/src/mibew/libs/classes/Mibew/Maintenance/Installer.php +++ b/src/mibew/libs/classes/Mibew/Maintenance/Installer.php @@ -489,7 +489,7 @@ class Installer */ protected function checkPhpExtensions() { - $extensions = array('PDO', 'pdo_mysql', 'gd'); + $extensions = array('PDO', 'pdo_mysql', 'gd', 'curl'); foreach ($extensions as $ext) { if (!extension_loaded($ext)) { diff --git a/src/mibew/libs/classes/Mibew/Maintenance/UpdateChecker.php b/src/mibew/libs/classes/Mibew/Maintenance/UpdateChecker.php new file mode 100644 index 00000000..d7ac0a4e --- /dev/null +++ b/src/mibew/libs/classes/Mibew/Maintenance/UpdateChecker.php @@ -0,0 +1,328 @@ +url = $url; + } + + /** + * Retrieves URL of updates server. + * + * @return string + */ + public function getUrl() + { + return is_null($this->url) + ? 'https://mibew.org/api/auto-updates' + : $this->url; + } + + /** + * Retrieves list of errors that took place during update checking process. + * + * @return array List of errors. Each item in the list is a error string. + */ + public function getErrors() + { + return $this->errors; + } + + /** + * Runs update checking process. + * + * @return boolean False on error and true otherwise. To get more info about + * error call {@link UpdateChecker::getErrors()} method. + */ + public function run() + { + $ch = curl_init($this->getUrl()); + + // TODO: set timeouts + curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_MAXREDIRS, 5); + + $json = json_encode($this->getSystemInfo()); + curl_setopt($ch, CURLOPT_POSTFIELDS, $json); + curl_setopt($ch, CURLOPT_HTTPHEADER, array( + 'Accept: application/json', + 'Content-Type: application/json', + 'Content-Length: ' . strlen($json) + )); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + + $body = curl_exec($ch); + $response_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curl_errno = curl_errno($ch); + $curl_error = curl_error($ch); + curl_close($ch); + + if ($curl_errno !== 0) { + // cURL request failed. + $this->errors[] = sprintf( + 'cURL error (#%u): %s', + $curl_errno, + $curl_error + ); + + return false; + } + + if ($response_code != 200) { + // Unexpected HTTP recieved. + $this->errors[] = sprintf( + 'Update server returns %u HTTP code instead of 200', + $response_code + ); + + return false; + } + + $updates = json_decode($body, true); + $json_error = json_last_error(); + if ($json_error !== JSON_ERROR_NONE) { + // Cannot parse JSON result. + $this->errors[] = $this->formatJsonError($json_error); + + return false; + } + + if (!$updates) { + // There are no available updates. + return true; + } + + return $this->processUpdates($updates); + } + + /** + * Retrieves set of system info that will be sent to updates server. + * + * @return array + */ + protected function getSystemInfo() + { + return array( + 'core' => MIBEW_VERSION, + 'plugins' => $this->getPluginsInfo(), + ); + } + + /** + * Retrieves info about plugins available in the system. + * + * @return array Associative array of plugins info. Each key of the array is + * fully qualified plugin's name and each value is an array with the + * fillowing keys: + * - "version": string, version of the plugin which presents in the system. + * - "installed": boolean, indicates if the plugin is installed. + * - "enabled": boolean, indicates if the plugin is enabled. + */ + protected function getPluginsInfo() + { + if (is_null($this->pluginsInfo)) { + $this->pluginsInfo = []; + $names = PluginUtils::discoverPlugins(); + foreach ($names as $plugin_name) { + $info = new PluginInfo($plugin_name); + $this->pluginsInfo[$plugin_name] = array( + 'version' => $info->getVersion(), + 'installed' => $info->getState()->installed, + 'enabled' => $info->getState()->enabled, + ); + } + } + + return $this->pluginsInfo; + } + + /** + * Performs all actions that are needed to prepare store available updates. + * + * @param array $updates Asscociative array of available updates that is + * retrieved from the updates server. + * @return boolean False on error and true otherwise. To get more info about + * error call {@link UpdateChecker::getErrors()} method. + */ + protected function processUpdates($updates) + { + // Process updates of the core. + if (version_compare($updates['core']['version'], MIBEW_VERSION) > 0) { + $update = $updates['core']; + // Save info about update for the core only if its version changed + $success = $this->saveUpdate( + 'core', + $update['version'], + $update['download'], + empty($update['description']) ? '' : $update['description'] + ); + if (!$success) { + // Something went wrong. The error is already logged so just + // notify the outer code. + return false; + } + } + + // Process plugins updates. + $plugins_info = $this->getPluginsInfo(); + foreach ($updates['plugins'] as $plugin_name => $update) { + if (!isset($plugins_info[$plugin_name])) { + // It's strange. We recieve update info for a plugin that does + // not exist in the system. Just do nothing. + continue; + } + + $info = $plugins_info[$plugin_name]; + + if (version_compare($update['version'], $info['version']) <= 0) { + // Version of the plugin is not updated. Just do nothing. + continue; + } + + // Save the update + $success = $this->saveUpdate( + $plugin_name, + $update['version'], + $update['download'], + empty($update['description']) ? '' : $update['description'] + ); + if (!$success) { + // Something went wrong. The error is already logged so just + // notify the outer code. + return false; + } + } + + return true; + } + + /** + * Saves record about available update in the database. + * + * @param string $target Update's target. Can be either "core" or fully + * qualified plugin's name. + * @param string $version The latest version at the updates server. + * @param string $url URL of the page where the update can be downloaded. + * @param string $description Arbitrary update's description. + * @return boolean False on failure and true otherwise. To get more info + * about the error call {@link UpdateChecker::getErrors()} method. + */ + protected function saveUpdate($target, $version, $url, $description = '') + { + try { + $update = AvailableUpdate::loadByTarget($target); + if (!$update) { + // There is no such update in the database. Create a new one. + $update = new AvailableUpdate(); + $update->target = $target; + } + + $update->version = $version; + $update->url = $url; + $update->description = $description; + + $update->save(); + } catch (\Exception $e) { + $this->errors[] = 'Cannot save available update: ' + $e->getMessage(); + + return false; + } + + return true; + } + + /** + * Builds human-readable message about error in json_* PHP's function. + * + * @param int $error_code Error code returned by json_last_error + * @return string Human-readable error message. + */ + protected function formatJsonError($error_code) + { + $errors = array( + JSON_ERROR_DEPTH => 'JSON_ERROR_DEPTH', + JSON_ERROR_STATE_MISMATCH => 'JSON_ERROR_STATE_MISMATCH', + JSON_ERROR_CTRL_CHAR => 'JSON_ERROR_CTRL_CHAR', + JSON_ERROR_SYNTAX => 'JSON_ERROR_SYNTAX', + ); + + // Following constants may be unavailable for the current PHP version. + if (defined('JSON_ERROR_UTF8')) { + $errors[JSON_ERROR_UTF8] = 'JSON_ERROR_UTF8'; + } + + if (defined('JSON_ERROR_RECURSION')) { + $errors[JSON_ERROR_RECURSION] = 'JSON_ERROR_RECURSION'; + } + + if (defined('JSON_ERROR_INF_OR_NAN')) { + $errors[JSON_ERROR_INF_OR_NAN] = 'JSON_ERROR_INF_OR_NAN'; + } + + if (defined('JSON_ERROR_UNSUPPORTED_TYPE')) { + $errors[JSON_ERROR_UNSUPPORTED_TYPE] = 'JSON_ERROR_UNSUPPORTED_TYPE'; + } + + $msg = isset($errors[$error_code]) ? $errors[$error_code] : 'UNKNOWN'; + + return sprintf( + 'Could not parse response from update server. The error is: "%s"', + $msg + ); + } +} diff --git a/src/mibew/libs/classes/Mibew/Maintenance/Updater.php b/src/mibew/libs/classes/Mibew/Maintenance/Updater.php index 5552af41..6936f3e5 100644 --- a/src/mibew/libs/classes/Mibew/Maintenance/Updater.php +++ b/src/mibew/libs/classes/Mibew/Maintenance/Updater.php @@ -332,14 +332,24 @@ class Updater return false; } - // Alter locale table. try { + // Alter locale table. $db->query('ALTER TABLE {locale} ADD COLUMN name varchar(128) NOT NULL DEFAULT "" AFTER code'); $db->query('ALTER TABLE {locale} ADD COLUMN rtl tinyint NOT NULL DEFAULT 0'); $db->query('ALTER TABLE {locale} ADD COLUMN time_locale varchar(128) NOT NULL DEFAULT "en_US"'); $db->query('ALTER TABLE {locale} ADD COLUMN date_format text'); $db->query('ALTER TABLE {locale} ADD UNIQUE KEY code (code)'); + + // Create a table for available updates. + $db->query('CREATE TABLE {available_update} ( ' + . 'id INT NOT NULL auto_increment PRIMARY KEY, ' + . 'target varchar(255) NOT NULL, ' + . 'version varchar(255) NOT NULL, ' + . 'url text, ' + . 'description text, ' + . 'UNIQUE KEY target (target) ' + . ') charset utf8 ENGINE=InnoDb'); } catch (\Exception $e) { $this->errors[] = getlocal('Cannot update tables: {0}', $e->getMessage());