From 9effd33bfac88322f7d73130aaa3078e766aaccc Mon Sep 17 00:00:00 2001 From: Yahnis Elsts Date: Mon, 12 Dec 2016 16:26:41 +0200 Subject: [PATCH] WIP: Theme updates --- Puc/v4/Metadata.php | 131 ++++++++++++++++++++++++++++++++ Puc/v4/Plugin/Info.php | 31 ++------ Puc/v4/Plugin/Update.php | 53 +++---------- Puc/v4/Plugin/UpdateChecker.php | 37 +++++---- Puc/v4/Theme/Update.php | 68 +++++++++++++++++ Puc/v4/Theme/UpdateChecker.php | 91 ++++++++++++++++++++++ Puc/v4/Update.php | 24 ++++++ Puc/v4/UpdateChecker.php | 101 ++++++++++++++++++++++++ debug-bar-plugin.php | 4 +- plugin-update-checker.php | 8 +- 10 files changed, 457 insertions(+), 91 deletions(-) create mode 100644 Puc/v4/Metadata.php create mode 100644 Puc/v4/Theme/Update.php create mode 100644 Puc/v4/Theme/UpdateChecker.php create mode 100644 Puc/v4/Update.php create mode 100644 Puc/v4/UpdateChecker.php diff --git a/Puc/v4/Metadata.php b/Puc/v4/Metadata.php new file mode 100644 index 0000000..8f7ee36 --- /dev/null +++ b/Puc/v4/Metadata.php @@ -0,0 +1,131 @@ +validateMetadata($apiResponse); + if ( is_wp_error($valid) ){ + trigger_error($valid->get_error_message(), E_USER_NOTICE); + return false; + } + + foreach(get_object_vars($apiResponse) as $key => $value){ + $target->$key = $value; + } + + return true; + } + + /** + * No validation by default! Subclasses should check that the required fields are present. + * + * @param StdClass $apiResponse + * @return bool|WP_Error + */ + protected function validateMetadata(/** @noinspection PhpUnusedParameterInspection */ $apiResponse) { + return true; + } + + /** + * Create a new instance by copying the necessary fields from another object. + * + * @abstract + * @param StdClass|self $object The source object. + * @return self The new copy. + */ + public static function fromObject(/** @noinspection PhpUnusedParameterInspection */ $object) { + throw new LogicException('The ' . __METHOD__ . ' method must be implemented by subclasses'); + } + + /** + * Create an instance of StdClass that can later be converted back to an + * update or info container. Useful for serialization and caching, as it + * avoids the "incomplete object" problem if the cached value is loaded + * before this class. + * + * @return StdClass + */ + public function toStdClass() { + $object = new stdClass(); + $this->copyFields($this, $object); + return $object; + } + + /** + * Transform the metadata into the format used by WordPress core. + * + * @return object + */ + abstract public function toWpFormat(); + + /** + * Copy known fields from one object to another. + * + * @param StdClass|self $from + * @param StdClass|self $to + */ + protected function copyFields($from, $to) { + $fields = $this->getFieldNames(); + + if ( property_exists($from, 'slug') && !empty($from->slug) ) { + //Let plugins add extra fields without having to create subclasses. + $fields = apply_filters($this->getFilterPrefix() . 'retain_fields-' . $from->slug, $fields); + } + + foreach ($fields as $field) { + if ( property_exists($from, $field) ) { + $to->$field = $from->$field; + } + } + } + + /** + * @return string[] + */ + protected function getFieldNames() { + return array(); + } + + /** + * @return string + */ + protected function getFilterPrefix() { + return 'puc_'; + } + } + +endif; \ No newline at end of file diff --git a/Puc/v4/Plugin/Info.php b/Puc/v4/Plugin/Info.php index 50f613e..3fe4432 100644 --- a/Puc/v4/Plugin/Info.php +++ b/Puc/v4/Plugin/Info.php @@ -8,7 +8,7 @@ if ( !class_exists('Puc_v4_Plugin_Info', false) ): * @copyright 2016 * @access public */ - class Puc_v4_Plugin_Info { + class Puc_v4_Plugin_Info extends Puc_v4_Metadata { //Most fields map directly to the contents of the plugin's info.json file. //See the relevant docs for a description of their meaning. public $name; @@ -38,38 +38,23 @@ if ( !class_exists('Puc_v4_Plugin_Info', false) ): public $filename; //Plugin filename relative to the plugins directory. /** - * Create a new instance of PluginInfo from JSON-encoded plugin info + * Create a new instance of Plugin Info from JSON-encoded plugin info * returned by an external update API. * * @param string $json Valid JSON string representing plugin info. - * @return Puc_v4_Plugin_Info|null New instance of PluginInfo, or NULL on error. + * @return self|null New instance of Plugin Info, or NULL on error. */ public static function fromJson($json){ - /** @var StdClass $apiResponse */ - $apiResponse = json_decode($json); - if ( empty($apiResponse) || !is_object($apiResponse) ){ - trigger_error( - "Failed to parse plugin metadata. Try validating your .json file with http://jsonlint.com/", - E_USER_NOTICE - ); - return null; - } + $instance = new self(); - $valid = self::validateMetadata($apiResponse); - if ( is_wp_error($valid) ){ - trigger_error($valid->get_error_message(), E_USER_NOTICE); + if ( !parent::createFromJson($json, $instance) ) { return null; } - $info = new self(); - foreach(get_object_vars($apiResponse) as $key => $value){ - $info->$key = $value; - } - //json_decode decodes assoc. arrays as objects. We want it as an array. - $info->sections = (array)$info->sections; + $instance->sections = (array)$instance->sections; - return $info; + return $instance; } /** @@ -78,7 +63,7 @@ if ( !class_exists('Puc_v4_Plugin_Info', false) ): * @param StdClass $apiResponse * @return bool|WP_Error */ - protected static function validateMetadata($apiResponse) { + protected function validateMetadata($apiResponse) { if ( !isset($apiResponse->name, $apiResponse->version) || empty($apiResponse->name) diff --git a/Puc/v4/Plugin/Update.php b/Puc/v4/Plugin/Update.php index b8dcd34..c81127a 100644 --- a/Puc/v4/Plugin/Update.php +++ b/Puc/v4/Plugin/Update.php @@ -9,21 +9,16 @@ if ( !class_exists('Puc_v4_Plugin_Update', false) ): * @version 3.2 * @access public */ - class Puc_v4_Plugin_Update { + class Puc_v4_Plugin_Update extends Puc_v4_Update { public $id = 0; - public $slug; - public $version; public $homepage; - public $download_url; public $upgrade_notice; public $tested; - public $translations = array(); public $filename; //Plugin filename relative to the plugins directory. - private static $fields = array( - 'id', 'slug', 'version', 'homepage', 'tested', - 'download_url', 'upgrade_notice', 'filename', - 'translations' + protected static $extraFields = array( + 'id', 'homepage', 'tested', 'download_url', 'upgrade_notice', + 'filename', 'translations', ); /** @@ -56,62 +51,34 @@ if ( !class_exists('Puc_v4_Plugin_Update', false) ): } /** - * Create a new instance of PluginUpdate by copying the necessary fields from - * another object. + * Create a new instance by copying the necessary fields from another object. * * @param StdClass|Puc_v4_Plugin_Info|Puc_v4_Plugin_Update $object The source object. * @return Puc_v4_Plugin_Update The new copy. */ public static function fromObject($object) { $update = new self(); - $fields = self::$fields; - if ( !empty($object->slug) ) { - $fields = apply_filters('puc_retain_fields-' . $object->slug, $fields); - } - foreach($fields as $field){ - if (property_exists($object, $field)) { - $update->$field = $object->$field; - } - } + $update->copyFields($object, $update); return $update; } /** - * Create an instance of StdClass that can later be converted back to - * a PluginUpdate. Useful for serialization and caching, as it avoids - * the "incomplete object" problem if the cached value is loaded before - * this class. - * - * @return StdClass + * @return string[] */ - public function toStdClass() { - $object = new stdClass(); - $fields = self::$fields; - if ( !empty($this->slug) ) { - $fields = apply_filters('puc_retain_fields-' . $this->slug, $fields); - } - foreach($fields as $field){ - if (property_exists($this, $field)) { - $object->$field = $this->$field; - } - } - return $object; + protected function getFieldNames() { + return array_merge(parent::getFieldNames(), self::$extraFields); } - /** * Transform the update into the format used by WordPress native plugin API. * * @return object */ public function toWpFormat(){ - $update = new stdClass; + $update = parent::toWpFormat(); $update->id = $this->id; - $update->slug = $this->slug; - $update->new_version = $this->version; $update->url = $this->homepage; - $update->package = $this->download_url; $update->tested = $this->tested; $update->plugin = $this->filename; diff --git a/Puc/v4/Plugin/UpdateChecker.php b/Puc/v4/Plugin/UpdateChecker.php index 36be6c9..1fae08c 100644 --- a/Puc/v4/Plugin/UpdateChecker.php +++ b/Puc/v4/Plugin/UpdateChecker.php @@ -8,15 +8,14 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): * @copyright 2016 * @access public */ - class Puc_v4_Plugin_UpdateChecker { + class Puc_v4_Plugin_UpdateChecker extends Puc_v4_UpdateChecker { public $metadataUrl = ''; //The URL of the plugin's metadata file. public $pluginAbsolutePath = ''; //Full path of the main plugin file. public $pluginFile = ''; //Plugin filename relative to the plugins directory. Many WP APIs use this to identify plugins. public $slug = ''; //Plugin slug. - public $optionName = ''; //Where to store the update info. public $muPluginFile = ''; //For MU plugins, the plugin filename relative to the mu-plugins directory. - public $debugMode = false; //Set to TRUE to enable error reporting. Errors are raised using trigger_error() + //Set to TRUE to enable error reporting. Errors are raised using trigger_error() //and should be logged to the standard PHP error log. public $scheduler; @@ -224,7 +223,7 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): * @param array|WP_Error $result * @return true|WP_Error */ - private function validateApiResponse($result) { + protected function validateApiResponse($result) { if ( is_wp_error($result) ) { /** @var WP_Error $result */ return new WP_Error($result->get_error_code(), 'WP HTTP Error: ' . $result->get_error_message()); } @@ -254,7 +253,7 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): * * @return Puc_v4_Plugin_Update An instance of PluginUpdate, or NULL when no updates are available. */ - public function requestUpdate(){ + public function requestUpdate() { //For the sake of simplicity, this function just calls requestInfo() //and transforms the result accordingly. $pluginInfo = $this->requestInfo(array('checking_for_updates' => '1')); @@ -563,7 +562,7 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): $convertedUpdate = array_merge( array( 'type' => $translationType, - 'slug' => $this->slug, + 'slug' => $this->slug, //FIXME: This should actually be the directory name, not the internal slug. 'autoupdate' => 0, //AFAICT, WordPress doesn't actually use the "version" field for anything. //But lets make sure it's there, just in case. @@ -752,7 +751,7 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): $isRelevant = ($pluginFile == $this->pluginFile) || (!empty($this->muPluginFile) && $pluginFile == $this->muPluginFile); - if ( $isRelevant && current_user_can('update_plugins') ) { + if ( $isRelevant && $this->userCanInstallUpdates() ) { $linkUrl = wp_nonce_url( add_query_arg( array( @@ -766,6 +765,7 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): $linkText = apply_filters('puc_manual_check_link-' . $this->slug, __('Check for updates', 'plugin-update-checker')); if ( !empty($linkText) ) { + /** @noinspection HtmlUnknownTarget */ $pluginMeta[] = sprintf('%s', esc_attr($linkUrl), $linkText); } } @@ -782,7 +782,7 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): $shouldCheck = isset($_GET['puc_check_for_updates'], $_GET['puc_slug']) && $_GET['puc_slug'] == $this->slug - && current_user_can('update_plugins') + && $this->userCanInstallUpdates() && check_admin_referer('puc_check_for_updates'); if ( $shouldCheck ) { @@ -821,6 +821,15 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): } } + /** + * Check if the current user has the required permissions to install updates. + * + * @return bool + */ + public function userCanInstallUpdates() { + return current_user_can('update_plugins'); + } + /** * Check if the plugin file is inside the mu-plugins directory. * @@ -949,17 +958,7 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): } } - /** - * Trigger a PHP error, but only when $debugMode is enabled. - * - * @param string $message - * @param int $errorType - */ - protected function triggerError($message, $errorType) { - if ( $this->debugMode ) { - trigger_error($message, $errorType); - } - } + } endif; \ No newline at end of file diff --git a/Puc/v4/Theme/Update.php b/Puc/v4/Theme/Update.php new file mode 100644 index 0000000..e0617e1 --- /dev/null +++ b/Puc/v4/Theme/Update.php @@ -0,0 +1,68 @@ +theme = $this->slug; + $update->new_version = $this->version; + $update->package = $this->download_url; + $update->details_url = $this->details_url; + + return $update; + } + + /** + * Create a new instance of Theme_Update from its JSON-encoded representation. + * + * @param string $json Valid JSON string representing a theme information object. + * @return self New instance of ThemeUpdate, or NULL on error. + */ + public static function fromJson($json) { + $instance = new self(); + if ( !parent::createFromJson($json, $instance) ) { + return null; + } + return $instance; + } + + /** + * Basic validation. + * + * @param StdClass $apiResponse + * @return bool|WP_Error + */ + protected function validateMetadata($apiResponse) { + $required = array('version', 'details_url'); + foreach($required as $key) { + if ( !isset($apiResponse->$key) || empty($apiResponse->$key) ) { + return new WP_Error( + 'tuc-invalid-metadata', + sprintf('The theme metadata is missing the required "%s" key.', $key) + ); + } + } + return true; + } + + protected function getFieldNames() { + return array_merge(parent::getFieldNames(), self::$extraFields); + } + + protected function getFilterPrefix() { + return 'tuc_'; + } + } + +endif; \ No newline at end of file diff --git a/Puc/v4/Theme/UpdateChecker.php b/Puc/v4/Theme/UpdateChecker.php new file mode 100644 index 0000000..cfee138 --- /dev/null +++ b/Puc/v4/Theme/UpdateChecker.php @@ -0,0 +1,91 @@ +stylesheet = $stylesheet; + $this->theme = wp_get_theme($this->stylesheet); + + parent::__construct($metadataUrl, $customSlug ? $customSlug : $stylesheet); + } + + /** + * Retrieve the latest update (if any) from the configured API endpoint. + * + * @return Puc_v4_Update An instance of Update, or NULL when no updates are available. + */ + public function requestUpdate() { + //Query args to append to the URL. Themes can add their own by using a filter callback (see addQueryArgFilter()). + $queryArgs = array(); + $installedVersion = $this->getInstalledVersion(); + $queryArgs['installed_version'] = ($installedVersion !== null) ? $installedVersion : ''; + + $queryArgs = apply_filters($this->filterPrefix . 'request_update_query_args-' . $this->slug, $queryArgs); + + //Various options for the wp_remote_get() call. Plugins can filter these, too. + $options = array( + 'timeout' => 10, //seconds + 'headers' => array( + 'Accept' => 'application/json' + ), + ); + $options = apply_filters($this->filterPrefix . 'request_update_options-' . $this->slug, $options); + + $url = $this->metadataUrl; + if ( !empty($queryArgs) ){ + $url = add_query_arg($queryArgs, $url); + } + + $result = wp_remote_get($url, $options); + + //Try to parse the response + $status = $this->validateApiResponse($result); + $themeUpdate = null; + if ( !is_wp_error($status) ){ + $themeUpdate = Puc_v4_Theme_Update::fromJson($result['body']); + if ( $themeUpdate !== null ) { + $themeUpdate->slug = $this->slug; + } + } else { + $this->triggerError( + sprintf('The URL %s does not point to a valid theme metadata file. ', $url) + . $status->get_error_message(), + E_USER_WARNING + ); + } + + $themeUpdate = apply_filters( + $this->filterPrefix . 'request_update_result-' . $this->slug, + $themeUpdate, + $result + ); + return $themeUpdate; + } + + /** + * Get the currently installed version of the plugin or theme. + * + * @return string Version number. + */ + public function getInstalledVersion() { + return $this->theme->get('Version'); + } + } + +endif; \ No newline at end of file diff --git a/Puc/v4/Update.php b/Puc/v4/Update.php new file mode 100644 index 0000000..f5a28b5 --- /dev/null +++ b/Puc/v4/Update.php @@ -0,0 +1,24 @@ +debugMode = (bool)(constant('WP_DEBUG')); + $this->metadataUrl = $metadataUrl; + $this->slug = $slug; + + $this->optionName = $optionName; + if ( empty($this->optionName) ) { + //BC: Initially the library only supported plugin updates and didn't use type prefixes + //in the option name. Lets use the same prefix-less name when possible. + if ( $this->filterPrefix === 'puc_' ) { + $this->optionName = 'external_updates-' . $this->slug; + } else { + $this->optionName = $this->filterPrefix . 'external_updates-' . $this->slug; + } + } + } + + /** + * Retrieve the latest update (if any) from the configured API endpoint. + * + * @return Puc_v4_Update An instance of Update, or NULL when no updates are available. + */ + abstract public function requestUpdate(); + + /** + * Check if $result is a successful update API response. + * + * @param array|WP_Error $result + * @return true|WP_Error + */ + protected function validateApiResponse($result) { + if ( is_wp_error($result) ) { /** @var WP_Error $result */ + return new WP_Error($result->get_error_code(), 'WP HTTP Error: ' . $result->get_error_message()); + } + + if ( !isset($result['response']['code']) ) { + return new WP_Error( + $this->filterPrefix . 'no_response_code', + 'wp_remote_get() returned an unexpected result.' + ); + } + + if ( $result['response']['code'] !== 200 ) { + return new WP_Error( + $this->filterPrefix . 'unexpected_response_code', + 'HTTP response code is ' . $result['response']['code'] . ' (expected: 200)' + ); + } + + if ( empty($result['body']) ) { + return new WP_Error($this->filterPrefix . 'empty_response', 'The metadata file appears to be empty.'); + } + + return true; + } + + /** + * Get the currently installed version of the plugin or theme. + * + * @return string Version number. + */ + abstract public function getInstalledVersion(); + + /** + * Trigger a PHP error, but only when $debugMode is enabled. + * + * @param string $message + * @param int $errorType + */ + protected function triggerError($message, $errorType) { + if ($this->debugMode) { + trigger_error($message, $errorType); + } + } + } + +endif; \ No newline at end of file diff --git a/debug-bar-plugin.php b/debug-bar-plugin.php index d58e48b..ed085dc 100644 --- a/debug-bar-plugin.php +++ b/debug-bar-plugin.php @@ -23,7 +23,7 @@ class PucDebugBarPlugin_3_2 { */ public function addDebugBarPanel($panels) { require_once dirname(__FILE__) . '/debug-bar-panel.php'; - if ( current_user_can('update_plugins') && class_exists('PluginUpdateCheckerPanel_3_2', false) ) { + if ( $this->updateChecker->userCanInstallUpdates() && class_exists('PluginUpdateCheckerPanel_3_2', false) ) { $panels[] = new PluginUpdateCheckerPanel_3_2($this->updateChecker); } return $panels; @@ -89,7 +89,7 @@ class PucDebugBarPlugin_3_2 { * Check access permissions and enable error display (for debugging). */ private function preAjaxReqest() { - if ( !current_user_can('update_plugins') ) { + if ( !$this->updateChecker->userCanInstallUpdates() ) { die('Access denied'); } check_ajax_referer('puc-ajax'); diff --git a/plugin-update-checker.php b/plugin-update-checker.php index 18db340..c89ba13 100644 --- a/plugin-update-checker.php +++ b/plugin-update-checker.php @@ -101,7 +101,7 @@ class PucFactory { endif; //Register classes defined in this file with the factory. -PucFactory::addVersion('PluginUpdateChecker', 'Puc_v4_Plugin_UpdateChecker', '3.2'); -PucFactory::addVersion('PluginUpdate', 'Puc_v4_Plugin_Update', '3.2'); -PucFactory::addVersion('PluginInfo', 'Puc_v4_Plugin_Info', '3.2'); -PucFactory::addVersion('PucGitHubChecker', 'Puc_v4_GitHub_PluginChecker', '3.2'); +PucFactory::addVersion('PluginUpdateChecker', 'Puc_v4_Plugin_UpdateChecker', '4.0'); +PucFactory::addVersion('PluginUpdate', 'Puc_v4_Plugin_Update', '4.0'); +PucFactory::addVersion('PluginInfo', 'Puc_v4_Plugin_Info', '4.0'); +PucFactory::addVersion('PucGitHubChecker', 'Puc_v4_GitHub_PluginChecker', '4.0');