From 79c24394643404a93e73ab84573e0486fa5dbddd Mon Sep 17 00:00:00 2001 From: Yahnis Elsts Date: Thu, 8 Dec 2016 16:56:24 +0200 Subject: [PATCH 01/44] Start reorganizing directory structure to comply with PSR-0 (mostly). --- Puc/v4/Autoloader.php | 27 + .../v4/GitHub/PluginChecker.php | 13 +- Puc/v4/Plugin/Info.php | 142 ++ Puc/v4/Plugin/Update.php | 126 ++ Puc/v4/Plugin/UpdateChecker.php | 965 ++++++++++ Puc/v4/Scheduler.php | 176 ++ Puc/v4/UpgraderStatus.php | 144 ++ debug-bar-panel.php | 2 +- debug-bar-plugin.php | 2 +- plugin-update-checker.php | 1572 +---------------- 10 files changed, 1597 insertions(+), 1572 deletions(-) create mode 100644 Puc/v4/Autoloader.php rename github-checker.php => Puc/v4/GitHub/PluginChecker.php (97%) create mode 100644 Puc/v4/Plugin/Info.php create mode 100644 Puc/v4/Plugin/Update.php create mode 100644 Puc/v4/Plugin/UpdateChecker.php create mode 100644 Puc/v4/Scheduler.php create mode 100644 Puc/v4/UpgraderStatus.php diff --git a/Puc/v4/Autoloader.php b/Puc/v4/Autoloader.php new file mode 100644 index 0000000..1d8a83d --- /dev/null +++ b/Puc/v4/Autoloader.php @@ -0,0 +1,27 @@ +rootDir = dirname(__FILE__) . '/'; + $nameParts = explode('_', __CLASS__, 3); + $this->prefix = $nameParts[0] . '_' . $nameParts[1] . '_'; + + spl_autoload_register(array($this, 'autoload')); + } + + public function autoload($className) { + if (strpos($className, $this->prefix) === 0) { + $path = substr($className, strlen($this->prefix)); + $path = str_replace('_', '/', $path); + $path = $this->rootDir . $path . '.php'; + + if (file_exists($path)) { + /** @noinspection PhpIncludeInspection */ + include $path; + } + } + } +} \ No newline at end of file diff --git a/github-checker.php b/Puc/v4/GitHub/PluginChecker.php similarity index 97% rename from github-checker.php rename to Puc/v4/GitHub/PluginChecker.php index 96a5ab9..659bea7 100644 --- a/github-checker.php +++ b/Puc/v4/GitHub/PluginChecker.php @@ -1,8 +1,8 @@ filename = $this->pluginFile; $info->slug = $this->slug; @@ -359,7 +359,7 @@ class PucGitHubChecker_3_2 extends PluginUpdateChecker_3_2 { * Copy plugin metadata from a file header to a PluginInfo object. * * @param array $fileHeader - * @param PluginInfo_3_2 $pluginInfo + * @param Puc_v4_Plugin_Info $pluginInfo */ protected function setInfoFromHeader($fileHeader, $pluginInfo) { $headerToPropertyMap = array( @@ -390,7 +390,7 @@ class PucGitHubChecker_3_2 extends PluginUpdateChecker_3_2 { * Copy plugin metadata from the remote readme.txt file. * * @param string $ref GitHub tag or branch where to look for the readme. - * @param PluginInfo_3_2 $pluginInfo + * @param Puc_v4_Plugin_Info $pluginInfo */ protected function setInfoFromRemoteReadme($ref, $pluginInfo) { $readmeTxt = $this->getRemoteFile('readme.txt', $ref); @@ -416,6 +416,7 @@ class PucGitHubChecker_3_2 extends PluginUpdateChecker_3_2 { } protected function parseReadme($content) { + //TODO: Autoload this and Parsedown. if ( !class_exists('PucReadmeParser', false) ) { require_once(dirname(__FILE__) . '/vendor/readme-parser.php'); } diff --git a/Puc/v4/Plugin/Info.php b/Puc/v4/Plugin/Info.php new file mode 100644 index 0000000..50f613e --- /dev/null +++ b/Puc/v4/Plugin/Info.php @@ -0,0 +1,142 @@ +get_error_message(), E_USER_NOTICE); + 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; + + return $info; + } + + /** + * Very, very basic validation. + * + * @param StdClass $apiResponse + * @return bool|WP_Error + */ + protected static function validateMetadata($apiResponse) { + if ( + !isset($apiResponse->name, $apiResponse->version) + || empty($apiResponse->name) + || empty($apiResponse->version) + ) { + return new WP_Error( + 'puc-invalid-metadata', + "The plugin metadata file does not contain the required 'name' and/or 'version' keys." + ); + } + return true; + } + + + /** + * Transform plugin info into the format used by the native WordPress.org API + * + * @return object + */ + public function toWpFormat(){ + $info = new stdClass; + + //The custom update API is built so that many fields have the same name and format + //as those returned by the native WordPress.org API. These can be assigned directly. + $sameFormat = array( + 'name', 'slug', 'version', 'requires', 'tested', 'rating', 'upgrade_notice', + 'num_ratings', 'downloaded', 'active_installs', 'homepage', 'last_updated', + ); + foreach($sameFormat as $field){ + if ( isset($this->$field) ) { + $info->$field = $this->$field; + } else { + $info->$field = null; + } + } + + //Other fields need to be renamed and/or transformed. + $info->download_link = $this->download_url; + $info->author = $this->getFormattedAuthor(); + $info->sections = array_merge(array('description' => ''), $this->sections); + + if ( !empty($this->banners) ) { + //WP expects an array with two keys: "high" and "low". Both are optional. + //Docs: https://wordpress.org/plugins/about/faq/#banners + $info->banners = is_object($this->banners) ? get_object_vars($this->banners) : $this->banners; + $info->banners = array_intersect_key($info->banners, array('high' => true, 'low' => true)); + } + + return $info; + } + + protected function getFormattedAuthor() { + if ( !empty($this->author_homepage) ){ + /** @noinspection HtmlUnknownTarget */ + return sprintf('%s', $this->author_homepage, $this->author); + } + return $this->author; + } + } + +endif; \ No newline at end of file diff --git a/Puc/v4/Plugin/Update.php b/Puc/v4/Plugin/Update.php new file mode 100644 index 0000000..b8dcd34 --- /dev/null +++ b/Puc/v4/Plugin/Update.php @@ -0,0 +1,126 @@ +slug) ) { + $fields = apply_filters('puc_retain_fields-' . $object->slug, $fields); + } + foreach($fields as $field){ + if (property_exists($object, $field)) { + $update->$field = $object->$field; + } + } + 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 + */ + 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; + } + + + /** + * Transform the update into the format used by WordPress native plugin API. + * + * @return object + */ + public function toWpFormat(){ + $update = new stdClass; + + $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; + + if ( !empty($this->upgrade_notice) ){ + $update->upgrade_notice = $this->upgrade_notice; + } + + return $update; + } + } + +endif; \ No newline at end of file diff --git a/Puc/v4/Plugin/UpdateChecker.php b/Puc/v4/Plugin/UpdateChecker.php new file mode 100644 index 0000000..36be6c9 --- /dev/null +++ b/Puc/v4/Plugin/UpdateChecker.php @@ -0,0 +1,965 @@ +metadataUrl = $metadataUrl; + $this->pluginAbsolutePath = $pluginFile; + $this->pluginFile = plugin_basename($this->pluginAbsolutePath); + $this->muPluginFile = $muPluginFile; + $this->slug = $slug; + $this->optionName = $optionName; + $this->debugMode = (bool)(constant('WP_DEBUG')); + + //If no slug is specified, use the name of the main plugin file as the slug. + //For example, 'my-cool-plugin/cool-plugin.php' becomes 'cool-plugin'. + if ( empty($this->slug) ){ + $this->slug = basename($this->pluginFile, '.php'); + } + + //Plugin slugs must be unique. + $slugCheckFilter = 'puc_is_slug_in_use-' . $this->slug; + $slugUsedBy = apply_filters($slugCheckFilter, false); + if ( $slugUsedBy ) { + $this->triggerError(sprintf( + 'Plugin slug "%s" is already in use by %s. Slugs must be unique.', + htmlentities($this->slug), + htmlentities($slugUsedBy) + ), E_USER_ERROR); + } + add_filter($slugCheckFilter, array($this, 'getAbsolutePath')); + + + if ( empty($this->optionName) ){ + $this->optionName = 'external_updates-' . $this->slug; + } + + //Backwards compatibility: If the plugin is a mu-plugin but no $muPluginFile is specified, assume + //it's the same as $pluginFile given that it's not in a subdirectory (WP only looks in the base dir). + if ( (strpbrk($this->pluginFile, '/\\') === false) && $this->isUnknownMuPlugin() ) { + $this->muPluginFile = $this->pluginFile; + } + + $this->scheduler = $this->createScheduler($checkPeriod); + $this->upgraderStatus = new Puc_v4_UpgraderStatus(); + + $this->installHooks(); + } + + /** + * Create an instance of the scheduler. + * + * This is implemented as a method to make it possible for plugins to subclass the update checker + * and substitute their own scheduler. + * + * @param int $checkPeriod + * @return Puc_v4_Scheduler + */ + protected function createScheduler($checkPeriod) { + return new Puc_v4_Scheduler($this, $checkPeriod); + } + + /** + * Install the hooks required to run periodic update checks and inject update info + * into WP data structures. + * + * @return void + */ + protected function installHooks(){ + //Override requests for plugin information + add_filter('plugins_api', array($this, 'injectInfo'), 20, 3); + + //Insert our update info into the update array maintained by WP. + add_filter('site_transient_update_plugins', array($this,'injectUpdate')); //WP 3.0+ + add_filter('transient_update_plugins', array($this,'injectUpdate')); //WP 2.8+ + add_filter('site_transient_update_plugins', array($this, 'injectTranslationUpdates')); + + add_filter('plugin_row_meta', array($this, 'addCheckForUpdatesLink'), 10, 2); + add_action('admin_init', array($this, 'handleManualCheck')); + add_action('all_admin_notices', array($this, 'displayManualCheckResult')); + + //Clear the version number cache when something - anything - is upgraded or WP clears the update cache. + add_filter('upgrader_post_install', array($this, 'clearCachedVersion')); + add_action('delete_site_transient_update_plugins', array($this, 'clearCachedVersion')); + //Clear translation updates when WP clears the update cache. + //This needs to be done directly because the library doesn't actually remove obsolete plugin updates, + //it just hides them (see getUpdate()). We can't do that with translations - too much disk I/O. + add_action('delete_site_transient_update_plugins', array($this, 'clearCachedTranslationUpdates')); + + if ( did_action('plugins_loaded') ) { + $this->initDebugBarPanel(); + } else { + add_action('plugins_loaded', array($this, 'initDebugBarPanel')); + } + + //Rename the update directory to be the same as the existing directory. + add_filter('upgrader_source_selection', array($this, 'fixDirectoryName'), 10, 3); + + //Enable language support (i18n). + load_plugin_textdomain('plugin-update-checker', false, plugin_basename(dirname(__FILE__)) . '/languages'); + + //Allow HTTP requests to the metadata URL even if it's on a local host. + $this->metadataHost = @parse_url($this->metadataUrl, PHP_URL_HOST); + add_filter('http_request_host_is_external', array($this, 'allowMetadataHost'), 10, 2); + } + + /** + * Explicitly allow HTTP requests to the metadata URL. + * + * WordPress has a security feature where the HTTP API will reject all requests that are sent to + * another site hosted on the same server as the current site (IP match), a local host, or a local + * IP, unless the host exactly matches the current site. + * + * This feature is opt-in (at least in WP 4.4). Apparently some people enable it. + * + * That can be a problem when you're developing your plugin and you decide to host the update information + * on the same server as your test site. Update requests will mysteriously fail. + * + * We fix that by adding an exception for the metadata host. + * + * @param bool $allow + * @param string $host + * @return bool + */ + public function allowMetadataHost($allow, $host) { + if ( strtolower($host) === strtolower($this->metadataHost) ) { + return true; + } + return $allow; + } + + /** + * Retrieve plugin info from the configured API endpoint. + * + * @uses wp_remote_get() + * + * @param array $queryArgs Additional query arguments to append to the request. Optional. + * @return Puc_v4_Plugin_Info + */ + public function requestInfo($queryArgs = array()){ + //Query args to append to the URL. Plugins can add their own by using a filter callback (see addQueryArgFilter()). + $installedVersion = $this->getInstalledVersion(); + $queryArgs['installed_version'] = ($installedVersion !== null) ? $installedVersion : ''; + $queryArgs = apply_filters('puc_request_info_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('puc_request_info_options-'.$this->slug, $options); + + //The plugin info should be at 'http://your-api.com/url/here/$slug/info.json' + $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); + $pluginInfo = null; + if ( !is_wp_error($status) ){ + $pluginInfo = Puc_v4_Plugin_Info::fromJson($result['body']); + if ( $pluginInfo !== null ) { + $pluginInfo->filename = $this->pluginFile; + $pluginInfo->slug = $this->slug; + } + } else { + $this->triggerError( + sprintf('The URL %s does not point to a valid plugin metadata file. ', $url) + . $status->get_error_message(), + E_USER_WARNING + ); + } + + $pluginInfo = apply_filters('puc_request_info_result-'.$this->slug, $pluginInfo, $result); + return $pluginInfo; + } + + /** + * Check if $result is a successful update API response. + * + * @param array|WP_Error $result + * @return true|WP_Error + */ + private 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('puc_no_response_code', 'wp_remote_get() returned an unexpected result.'); + } + + if ( $result['response']['code'] !== 200 ) { + return new WP_Error( + 'puc_unexpected_response_code', + 'HTTP response code is ' . $result['response']['code'] . ' (expected: 200)' + ); + } + + if ( empty($result['body']) ) { + return new WP_Error('puc_empty_response', 'The metadata file appears to be empty.'); + } + + return true; + } + + /** + * Retrieve the latest update (if any) from the configured API endpoint. + * + * @uses PluginUpdateChecker::requestInfo() + * + * @return Puc_v4_Plugin_Update An instance of PluginUpdate, or NULL when no updates are available. + */ + 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')); + if ( $pluginInfo == null ){ + return null; + } + $update = Puc_v4_Plugin_Update::fromPluginInfo($pluginInfo); + + //Keep only those translation updates that apply to this site. + $update->translations = $this->filterApplicableTranslations($update->translations); + + return $update; + } + + /** + * Filter a list of translation updates and return a new list that contains only updates + * that apply to the current site. + * + * @param array $translations + * @return array + */ + private function filterApplicableTranslations($translations) { + $languages = array_flip(array_values(get_available_languages())); + $installedTranslations = wp_get_installed_translations('plugins'); + if ( isset($installedTranslations[$this->slug]) ) { + $installedTranslations = $installedTranslations[$this->slug]; + } else { + $installedTranslations = array(); + } + + $applicableTranslations = array(); + foreach($translations as $translation) { + //Does it match one of the available core languages? + $isApplicable = array_key_exists($translation->language, $languages); + //Is it more recent than an already-installed translation? + if ( isset($installedTranslations[$translation->language]) ) { + $updateTimestamp = strtotime($translation->updated); + $installedTimestamp = strtotime($installedTranslations[$translation->language]['PO-Revision-Date']); + $isApplicable = $updateTimestamp > $installedTimestamp; + } + + if ( $isApplicable ) { + $applicableTranslations[] = $translation; + } + } + + return $applicableTranslations; + } + + /** + * Get the currently installed version of the plugin. + * + * @return string Version number. + */ + public function getInstalledVersion(){ + if ( isset($this->cachedInstalledVersion) ) { + return $this->cachedInstalledVersion; + } + + $pluginHeader = $this->getPluginHeader(); + if ( isset($pluginHeader['Version']) ) { + $this->cachedInstalledVersion = $pluginHeader['Version']; + return $pluginHeader['Version']; + } else { + //This can happen if the filename points to something that is not a plugin. + $this->triggerError( + sprintf( + "Can't to read the Version header for '%s'. The filename is incorrect or is not a plugin.", + $this->pluginFile + ), + E_USER_WARNING + ); + return null; + } + } + + /** + * Get plugin's metadata from its file header. + * + * @return array + */ + protected function getPluginHeader() { + if ( !is_file($this->pluginAbsolutePath) ) { + //This can happen if the plugin filename is wrong. + $this->triggerError( + sprintf( + "Can't to read the plugin header for '%s'. The file does not exist.", + $this->pluginFile + ), + E_USER_WARNING + ); + return array(); + } + + if ( !function_exists('get_plugin_data') ){ + /** @noinspection PhpIncludeInspection */ + require_once( ABSPATH . '/wp-admin/includes/plugin.php' ); + } + return get_plugin_data($this->pluginAbsolutePath, false, false); + } + + /** + * Check for plugin updates. + * The results are stored in the DB option specified in $optionName. + * + * @return Puc_v4_Plugin_Update|null + */ + public function checkForUpdates(){ + $installedVersion = $this->getInstalledVersion(); + //Fail silently if we can't find the plugin or read its header. + if ( $installedVersion === null ) { + $this->triggerError( + sprintf('Skipping update check for %s - installed version unknown.', $this->pluginFile), + E_USER_WARNING + ); + return null; + } + + $state = $this->getUpdateState(); + if ( empty($state) ){ + $state = new stdClass; + $state->lastCheck = 0; + $state->checkedVersion = ''; + $state->update = null; + } + + $state->lastCheck = time(); + $state->checkedVersion = $installedVersion; + $this->setUpdateState($state); //Save before checking in case something goes wrong + + $state->update = $this->requestUpdate(); + $this->setUpdateState($state); + + return $this->getUpdate(); + } + + /** + * Load the update checker state from the DB. + * + * @return stdClass|null + */ + public function getUpdateState() { + $state = get_site_option($this->optionName, null); + if ( empty($state) || !is_object($state)) { + $state = null; + } + + if ( isset($state, $state->update) && is_object($state->update) ) { + $state->update = Puc_v4_Plugin_Update::fromObject($state->update); + } + return $state; + } + + + /** + * Persist the update checker state to the DB. + * + * @param StdClass $state + * @return void + */ + private function setUpdateState($state) { + if ( isset($state->update) && is_object($state->update) && method_exists($state->update, 'toStdClass') ) { + $update = $state->update; /** @var Puc_v4_Plugin_Update $update */ + $state->update = $update->toStdClass(); + } + update_site_option($this->optionName, $state); + } + + /** + * Reset update checker state - i.e. last check time, cached update data and so on. + * + * Call this when your plugin is being uninstalled, or if you want to + * clear the update cache. + */ + public function resetUpdateState() { + delete_site_option($this->optionName); + } + + /** + * Intercept plugins_api() calls that request information about our plugin and + * use the configured API endpoint to satisfy them. + * + * @see plugins_api() + * + * @param mixed $result + * @param string $action + * @param array|object $args + * @return mixed + */ + public function injectInfo($result, $action = null, $args = null){ + $relevant = ($action == 'plugin_information') && isset($args->slug) && ( + ($args->slug == $this->slug) || ($args->slug == dirname($this->pluginFile)) + ); + if ( !$relevant ) { + return $result; + } + + $pluginInfo = $this->requestInfo(); + $pluginInfo = apply_filters('puc_pre_inject_info-' . $this->slug, $pluginInfo); + if ( $pluginInfo ) { + return $pluginInfo->toWpFormat(); + } + + return $result; + } + + /** + * Insert the latest update (if any) into the update list maintained by WP. + * + * @param StdClass $updates Update list. + * @return StdClass Modified update list. + */ + public function injectUpdate($updates){ + //Is there an update to insert? + $update = $this->getUpdate(); + + //No update notifications for mu-plugins unless explicitly enabled. The MU plugin file + //is usually different from the main plugin file so the update wouldn't show up properly anyway. + if ( $this->isUnknownMuPlugin() ) { + $update = null; + } + + if ( !empty($update) ) { + //Let plugins filter the update info before it's passed on to WordPress. + $update = apply_filters('puc_pre_inject_update-' . $this->slug, $update); + $updates = $this->addUpdateToList($updates, $update); + } else { + //Clean up any stale update info. + $updates = $this->removeUpdateFromList($updates); + } + + return $updates; + } + + /** + * @param StdClass|null $updates + * @param Puc_v4_Plugin_Update $updateToAdd + * @return StdClass + */ + private function addUpdateToList($updates, $updateToAdd) { + if ( !is_object($updates) ) { + $updates = new stdClass(); + $updates->response = array(); + } + + $wpUpdate = $updateToAdd->toWpFormat(); + $pluginFile = $this->pluginFile; + + if ( $this->isMuPlugin() ) { + //WP does not support automatic update installation for mu-plugins, but we can still display a notice. + $wpUpdate->package = null; + $pluginFile = $this->muPluginFile; + } + $updates->response[$pluginFile] = $wpUpdate; + return $updates; + } + + /** + * @param stdClass|null $updates + * @return stdClass|null + */ + private function removeUpdateFromList($updates) { + if ( isset($updates, $updates->response) ) { + unset($updates->response[$this->pluginFile]); + if ( !empty($this->muPluginFile) ) { + unset($updates->response[$this->muPluginFile]); + } + } + return $updates; + } + + /** + * Insert translation updates into the list maintained by WordPress. + * + * @param stdClass $updates + * @return stdClass + */ + public function injectTranslationUpdates($updates) { + $translationUpdates = $this->getTranslationUpdates(); + if ( empty($translationUpdates) ) { + return $updates; + } + + //Being defensive. + if ( !is_object($updates) ) { + $updates = new stdClass(); + } + if ( !isset($updates->translations) ) { + $updates->translations = array(); + } + + //In case there's a name collision with a plugin hosted on wordpress.org, + //remove any preexisting updates that match our plugin. + $translationType = 'plugin'; + $filteredTranslations = array(); + foreach($updates->translations as $translation) { + if ( ($translation['type'] === $translationType) && ($translation['slug'] === $this->slug) ) { + continue; + } + $filteredTranslations[] = $translation; + } + $updates->translations = $filteredTranslations; + + //Add our updates to the list. + foreach($translationUpdates as $update) { + $convertedUpdate = array_merge( + array( + 'type' => $translationType, + 'slug' => $this->slug, + 'autoupdate' => 0, + //AFAICT, WordPress doesn't actually use the "version" field for anything. + //But lets make sure it's there, just in case. + 'version' => isset($update->version) ? $update->version : ('1.' . strtotime($update->updated)), + ), + (array)$update + ); + + $updates->translations[] = $convertedUpdate; + } + + return $updates; + } + + /** + * Rename the update directory to match the existing plugin directory. + * + * When WordPress installs a plugin or theme update, it assumes that the ZIP file will contain + * exactly one directory, and that the directory name will be the same as the directory where + * the plugin/theme is currently installed. + * + * GitHub and other repositories provide ZIP downloads, but they often use directory names like + * "project-branch" or "project-tag-hash". We need to change the name to the actual plugin folder. + * + * This is a hook callback. Don't call it from a plugin. + * + * @param string $source The directory to copy to /wp-content/plugins. Usually a subdirectory of $remoteSource. + * @param string $remoteSource WordPress has extracted the update to this directory. + * @param WP_Upgrader $upgrader + * @return string|WP_Error + */ + public function fixDirectoryName($source, $remoteSource, $upgrader) { + global $wp_filesystem; /** @var WP_Filesystem_Base $wp_filesystem */ + + //Basic sanity checks. + if ( !isset($source, $remoteSource, $upgrader, $upgrader->skin, $wp_filesystem) ) { + return $source; + } + + //If WordPress is upgrading anything other than our plugin, leave the directory name unchanged. + if ( !$this->isPluginBeingUpgraded($upgrader) ) { + return $source; + } + + //Rename the source to match the existing plugin directory. + $pluginDirectoryName = dirname($this->pluginFile); + if ( $pluginDirectoryName === '.' ) { + return $source; + } + $correctedSource = trailingslashit($remoteSource) . $pluginDirectoryName . '/'; + if ( $source !== $correctedSource ) { + //The update archive should contain a single directory that contains the rest of plugin files. Otherwise, + //WordPress will try to copy the entire working directory ($source == $remoteSource). We can't rename + //$remoteSource because that would break WordPress code that cleans up temporary files after update. + if ( $this->isBadDirectoryStructure($remoteSource) ) { + return new WP_Error( + 'puc-incorrect-directory-structure', + sprintf( + 'The directory structure of the update is incorrect. All plugin files should be inside ' . + 'a directory named %s, not at the root of the ZIP file.', + htmlentities($this->slug) + ) + ); + } + + /** @var WP_Upgrader_Skin $upgrader->skin */ + $upgrader->skin->feedback(sprintf( + 'Renaming %s to %s…', + '' . basename($source) . '', + '' . $pluginDirectoryName . '' + )); + + if ( $wp_filesystem->move($source, $correctedSource, true) ) { + $upgrader->skin->feedback('Plugin directory successfully renamed.'); + return $correctedSource; + } else { + return new WP_Error( + 'puc-rename-failed', + 'Unable to rename the update to match the existing plugin directory.' + ); + } + } + + return $source; + } + + /** + * Check for incorrect update directory structure. An update must contain a single directory, + * all other files should be inside that directory. + * + * @param string $remoteSource Directory path. + * @return bool + */ + private function isBadDirectoryStructure($remoteSource) { + global $wp_filesystem; /** @var WP_Filesystem_Base $wp_filesystem */ + + $sourceFiles = $wp_filesystem->dirlist($remoteSource); + if ( is_array($sourceFiles) ) { + $sourceFiles = array_keys($sourceFiles); + $firstFilePath = trailingslashit($remoteSource) . $sourceFiles[0]; + return (count($sourceFiles) > 1) || (!$wp_filesystem->is_dir($firstFilePath)); + } + + //Assume it's fine. + return false; + } + + /** + * Is there and update being installed RIGHT NOW, for this specific plugin? + * + * @param WP_Upgrader|null $upgrader The upgrader that's performing the current update. + * @return bool + */ + public function isPluginBeingUpgraded($upgrader = null) { + return $this->upgraderStatus->isPluginBeingUpgraded($this->pluginFile, $upgrader); + } + + /** + * Get the details of the currently available update, if any. + * + * If no updates are available, or if the last known update version is below or equal + * to the currently installed version, this method will return NULL. + * + * Uses cached update data. To retrieve update information straight from + * the metadata URL, call requestUpdate() instead. + * + * @return Puc_v4_Plugin_Update|null + */ + public function getUpdate() { + $state = $this->getUpdateState(); /** @var StdClass $state */ + + //Is there an update available? + if ( isset($state, $state->update) ) { + $update = $state->update; + //Check if the update is actually newer than the currently installed version. + $installedVersion = $this->getInstalledVersion(); + if ( ($installedVersion !== null) && version_compare($update->version, $installedVersion, '>') ){ + $update->filename = $this->pluginFile; + return $update; + } + } + return null; + } + + /** + * Get a list of available translation updates. + * + * This method will return an empty array if there are no updates. + * Uses cached update data. + * + * @return array + */ + public function getTranslationUpdates() { + $state = $this->getUpdateState(); + if ( isset($state, $state->update, $state->update->translations) ) { + return $state->update->translations; + } + return array(); + } + + /** + * Remove all cached translation updates. + * + * @see wp_clean_update_cache + */ + public function clearCachedTranslationUpdates() { + $state = $this->getUpdateState(); + if ( isset($state, $state->update, $state->update->translations) ) { + $state->update->translations = array(); + $this->setUpdateState($state); + } + } + + /** + * Add a "Check for updates" link to the plugin row in the "Plugins" page. By default, + * the new link will appear after the "Visit plugin site" link. + * + * You can change the link text by using the "puc_manual_check_link-$slug" filter. + * Returning an empty string from the filter will disable the link. + * + * @param array $pluginMeta Array of meta links. + * @param string $pluginFile + * @return array + */ + public function addCheckForUpdatesLink($pluginMeta, $pluginFile) { + $isRelevant = ($pluginFile == $this->pluginFile) + || (!empty($this->muPluginFile) && $pluginFile == $this->muPluginFile); + + if ( $isRelevant && current_user_can('update_plugins') ) { + $linkUrl = wp_nonce_url( + add_query_arg( + array( + 'puc_check_for_updates' => 1, + 'puc_slug' => $this->slug, + ), + self_admin_url('plugins.php') + ), + 'puc_check_for_updates' + ); + + $linkText = apply_filters('puc_manual_check_link-' . $this->slug, __('Check for updates', 'plugin-update-checker')); + if ( !empty($linkText) ) { + $pluginMeta[] = sprintf('%s', esc_attr($linkUrl), $linkText); + } + } + return $pluginMeta; + } + + /** + * Check for updates when the user clicks the "Check for updates" link. + * @see self::addCheckForUpdatesLink() + * + * @return void + */ + public function handleManualCheck() { + $shouldCheck = + isset($_GET['puc_check_for_updates'], $_GET['puc_slug']) + && $_GET['puc_slug'] == $this->slug + && current_user_can('update_plugins') + && check_admin_referer('puc_check_for_updates'); + + if ( $shouldCheck ) { + $update = $this->checkForUpdates(); + $status = ($update === null) ? 'no_update' : 'update_available'; + wp_redirect(add_query_arg( + array( + 'puc_update_check_result' => $status, + 'puc_slug' => $this->slug, + ), + self_admin_url('plugins.php') + )); + } + } + + /** + * Display the results of a manual update check. + * @see self::handleManualCheck() + * + * You can change the result message by using the "puc_manual_check_message-$slug" filter. + */ + public function displayManualCheckResult() { + if ( isset($_GET['puc_update_check_result'], $_GET['puc_slug']) && ($_GET['puc_slug'] == $this->slug) ) { + $status = strval($_GET['puc_update_check_result']); + if ( $status == 'no_update' ) { + $message = __('This plugin is up to date.', 'plugin-update-checker'); + } else if ( $status == 'update_available' ) { + $message = __('A new version of this plugin is available.', 'plugin-update-checker'); + } else { + $message = sprintf(__('Unknown update checker status "%s"', 'plugin-update-checker'), htmlentities($status)); + } + printf( + '

%s

', + apply_filters('puc_manual_check_message-' . $this->slug, $message, $status) + ); + } + } + + /** + * Check if the plugin file is inside the mu-plugins directory. + * + * @return bool + */ + protected function isMuPlugin() { + static $cachedResult = null; + + if ( $cachedResult === null ) { + //Convert both paths to the canonical form before comparison. + $muPluginDir = realpath(WPMU_PLUGIN_DIR); + $pluginPath = realpath($this->pluginAbsolutePath); + + $cachedResult = (strpos($pluginPath, $muPluginDir) === 0); + } + + return $cachedResult; + } + + /** + * MU plugins are partially supported, but only when we know which file in mu-plugins + * corresponds to this plugin. + * + * @return bool + */ + protected function isUnknownMuPlugin() { + return empty($this->muPluginFile) && $this->isMuPlugin(); + } + + /** + * Clear the cached plugin version. This method can be set up as a filter (hook) and will + * return the filter argument unmodified. + * + * @param mixed $filterArgument + * @return mixed + */ + public function clearCachedVersion($filterArgument = null) { + $this->cachedInstalledVersion = null; + return $filterArgument; + } + + /** + * Get absolute path to the main plugin file. + * + * @return string + */ + public function getAbsolutePath() { + return $this->pluginAbsolutePath; + } + + /** + * Register a callback for filtering query arguments. + * + * The callback function should take one argument - an associative array of query arguments. + * It should return a modified array of query arguments. + * + * @uses add_filter() This method is a convenience wrapper for add_filter(). + * + * @param callable $callback + * @return void + */ + public function addQueryArgFilter($callback){ + add_filter('puc_request_info_query_args-'.$this->slug, $callback); + } + + /** + * Register a callback for filtering arguments passed to wp_remote_get(). + * + * The callback function should take one argument - an associative array of arguments - + * and return a modified array or arguments. See the WP documentation on wp_remote_get() + * for details on what arguments are available and how they work. + * + * @uses add_filter() This method is a convenience wrapper for add_filter(). + * + * @param callable $callback + * @return void + */ + public function addHttpRequestArgFilter($callback){ + add_filter('puc_request_info_options-'.$this->slug, $callback); + } + + /** + * Register a callback for filtering the plugin info retrieved from the external API. + * + * The callback function should take two arguments. If the plugin info was retrieved + * successfully, the first argument passed will be an instance of PluginInfo. Otherwise, + * it will be NULL. The second argument will be the corresponding return value of + * wp_remote_get (see WP docs for details). + * + * The callback function should return a new or modified instance of PluginInfo or NULL. + * + * @uses add_filter() This method is a convenience wrapper for add_filter(). + * + * @param callable $callback + * @return void + */ + public function addResultFilter($callback){ + add_filter('puc_request_info_result-'.$this->slug, $callback, 10, 2); + } + + /** + * Register a callback for one of the update checker filters. + * + * Identical to add_filter(), except it automatically adds the "puc_" prefix + * and the "-$plugin_slug" suffix to the filter name. For example, "request_info_result" + * becomes "puc_request_info_result-your_plugin_slug". + * + * @param string $tag + * @param callable $callback + * @param int $priority + * @param int $acceptedArgs + */ + public function addFilter($tag, $callback, $priority = 10, $acceptedArgs = 1) { + add_filter('puc_' . $tag . '-' . $this->slug, $callback, $priority, $acceptedArgs); + } + + /** + * Initialize the update checker Debug Bar plugin/add-on thingy. + */ + public function initDebugBarPanel() { + $debugBarPlugin = dirname(__FILE__) . '/../../../debug-bar-plugin.php'; + if ( class_exists('Debug_Bar', false) && file_exists($debugBarPlugin) ) { + /** @noinspection PhpIncludeInspection */ + require_once $debugBarPlugin; + $this->debugBarPlugin = new PucDebugBarPlugin_3_2($this); + } + } + + /** + * 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/Scheduler.php b/Puc/v4/Scheduler.php new file mode 100644 index 0000000..160c99d --- /dev/null +++ b/Puc/v4/Scheduler.php @@ -0,0 +1,176 @@ +updateChecker = $updateChecker; + $this->checkPeriod = $checkPeriod; + + //Set up the periodic update checks + $this->cronHook = 'check_plugin_updates-' . $this->updateChecker->slug; + if ( $this->checkPeriod > 0 ){ + + //Trigger the check via Cron. + //Try to use one of the default schedules if possible as it's less likely to conflict + //with other plugins and their custom schedules. + $defaultSchedules = array( + 1 => 'hourly', + 12 => 'twicedaily', + 24 => 'daily', + ); + if ( array_key_exists($this->checkPeriod, $defaultSchedules) ) { + $scheduleName = $defaultSchedules[$this->checkPeriod]; + } else { + //Use a custom cron schedule. + $scheduleName = 'every' . $this->checkPeriod . 'hours'; + add_filter('cron_schedules', array($this, '_addCustomSchedule')); + } + + if ( !wp_next_scheduled($this->cronHook) && !defined('WP_INSTALLING') ) { + wp_schedule_event(time(), $scheduleName, $this->cronHook); + } + add_action($this->cronHook, array($this, 'maybeCheckForUpdates')); + + register_deactivation_hook($this->updateChecker->pluginFile, array($this, '_removeUpdaterCron')); + + //In case Cron is disabled or unreliable, we also manually trigger + //the periodic checks while the user is browsing the Dashboard. + add_action( 'admin_init', array($this, 'maybeCheckForUpdates') ); + + //Like WordPress itself, we check more often on certain pages. + /** @see wp_update_plugins */ + add_action('load-update-core.php', array($this, 'maybeCheckForUpdates')); + add_action('load-plugins.php', array($this, 'maybeCheckForUpdates')); + add_action('load-update.php', array($this, 'maybeCheckForUpdates')); + //This hook fires after a bulk update is complete. + add_action('upgrader_process_complete', array($this, 'maybeCheckForUpdates'), 11, 0); + + } else { + //Periodic checks are disabled. + wp_clear_scheduled_hook($this->cronHook); + } + } + + /** + * Check for updates if the configured check interval has already elapsed. + * Will use a shorter check interval on certain admin pages like "Dashboard -> Updates" or when doing cron. + * + * You can override the default behaviour by using the "puc_check_now-$slug" filter. + * The filter callback will be passed three parameters: + * - Current decision. TRUE = check updates now, FALSE = don't check now. + * - Last check time as a Unix timestamp. + * - Configured check period in hours. + * Return TRUE to check for updates immediately, or FALSE to cancel. + * + * This method is declared public because it's a hook callback. Calling it directly is not recommended. + */ + public function maybeCheckForUpdates(){ + if ( empty($this->checkPeriod) ){ + return; + } + + $state = $this->updateChecker->getUpdateState(); + $shouldCheck = + empty($state) || + !isset($state->lastCheck) || + ( (time() - $state->lastCheck) >= $this->getEffectiveCheckPeriod() ); + + //Let plugin authors substitute their own algorithm. + $shouldCheck = apply_filters( + 'puc_check_now-' . $this->updateChecker->slug, + $shouldCheck, + (!empty($state) && isset($state->lastCheck)) ? $state->lastCheck : 0, + $this->checkPeriod + ); + + if ( $shouldCheck ) { + $this->updateChecker->checkForUpdates(); + } + } + + /** + * Calculate the actual check period based on the current status and environment. + * + * @return int Check period in seconds. + */ + protected function getEffectiveCheckPeriod() { + $currentFilter = current_filter(); + if ( in_array($currentFilter, array('load-update-core.php', 'upgrader_process_complete')) ) { + //Check more often when the user visits "Dashboard -> Updates" or does a bulk update. + $period = 60; + } else if ( in_array($currentFilter, array('load-plugins.php', 'load-update.php')) ) { + //Also check more often on the "Plugins" page and /wp-admin/update.php. + $period = 3600; + } else if ( $this->throttleRedundantChecks && ($this->updateChecker->getUpdate() !== null) ) { + //Check less frequently if it's already known that an update is available. + $period = $this->throttledCheckPeriod * 3600; + } else if ( defined('DOING_CRON') && constant('DOING_CRON') ) { + //WordPress cron schedules are not exact, so lets do an update check even + //if slightly less than $checkPeriod hours have elapsed since the last check. + $cronFuzziness = 20 * 60; + $period = $this->checkPeriod * 3600 - $cronFuzziness; + } else { + $period = $this->checkPeriod * 3600; + } + + return $period; + } + + /** + * Add our custom schedule to the array of Cron schedules used by WP. + * + * @param array $schedules + * @return array + */ + public function _addCustomSchedule($schedules){ + if ( $this->checkPeriod && ($this->checkPeriod > 0) ){ + $scheduleName = 'every' . $this->checkPeriod . 'hours'; + $schedules[$scheduleName] = array( + 'interval' => $this->checkPeriod * 3600, + 'display' => sprintf('Every %d hours', $this->checkPeriod), + ); + } + return $schedules; + } + + /** + * Remove the scheduled cron event that the library uses to check for updates. + * + * @return void + */ + public function _removeUpdaterCron(){ + wp_clear_scheduled_hook($this->cronHook); + } + + /** + * Get the name of the update checker's WP-cron hook. Mostly useful for debugging. + * + * @return string + */ + public function getCronHookName() { + return $this->cronHook; + } + } + +endif; diff --git a/Puc/v4/UpgraderStatus.php b/Puc/v4/UpgraderStatus.php new file mode 100644 index 0000000..4aa8ec0 --- /dev/null +++ b/Puc/v4/UpgraderStatus.php @@ -0,0 +1,144 @@ +getPluginBeingUpgradedBy($upgrader); + if ( !empty($upgradedPluginFile) ) { + $this->upgradedPluginFile = $upgradedPluginFile; + } + } + return ( !empty($this->upgradedPluginFile) && ($this->upgradedPluginFile === $pluginFile) ); + } + + /** + * Get the file name of the plugin that's currently being upgraded. + * + * @param Plugin_Upgrader|WP_Upgrader $upgrader + * @return string|null + */ + private function getPluginBeingUpgradedBy($upgrader) { + if ( !isset($upgrader, $upgrader->skin) ) { + return null; + } + + //Figure out which plugin is being upgraded. + $pluginFile = null; + $skin = $upgrader->skin; + if ( $skin instanceof Plugin_Upgrader_Skin ) { + if ( isset($skin->plugin) && is_string($skin->plugin) && ($skin->plugin !== '') ) { + $pluginFile = $skin->plugin; + } + } elseif ( isset($skin->plugin_info) && is_array($skin->plugin_info) ) { + //This case is tricky because Bulk_Plugin_Upgrader_Skin (etc) doesn't actually store the plugin + //filename anywhere. Instead, it has the plugin headers in $plugin_info. So the best we can + //do is compare those headers to the headers of installed plugins. + $pluginFile = $this->identifyPluginByHeaders($skin->plugin_info); + } + + return $pluginFile; + } + + /** + * Identify an installed plugin based on its headers. + * + * @param array $searchHeaders The plugin file header to look for. + * @return string|null Plugin basename ("foo/bar.php"), or NULL if we can't identify the plugin. + */ + private function identifyPluginByHeaders($searchHeaders) { + if ( !function_exists('get_plugins') ){ + /** @noinspection PhpIncludeInspection */ + require_once( ABSPATH . '/wp-admin/includes/plugin.php' ); + } + + $installedPlugins = get_plugins(); + $matches = array(); + foreach($installedPlugins as $pluginBasename => $headers) { + $diff1 = array_diff_assoc($headers, $searchHeaders); + $diff2 = array_diff_assoc($searchHeaders, $headers); + if ( empty($diff1) && empty($diff2) ) { + $matches[] = $pluginBasename; + } + } + + //It's possible (though very unlikely) that there could be two plugins with identical + //headers. In that case, we can't unambiguously identify the plugin that's being upgraded. + if ( count($matches) !== 1 ) { + return null; + } + + return reset($matches); + } + + /** + * @access private + * + * @param mixed $input + * @param array $hookExtra + * @return mixed Returns $input unaltered. + */ + public function setUpgradedPlugin($input, $hookExtra) { + if (!empty($hookExtra['plugin']) && is_string($hookExtra['plugin'])) { + $this->upgradedPluginFile = $hookExtra['plugin']; + } else { + $this->upgradedPluginFile = null; + } + return $input; + } + + /** + * @access private + * + * @param array $options + * @return array + */ + public function setUpgradedPluginFromOptions($options) { + if (isset($options['hook_extra']['plugin']) && is_string($options['hook_extra']['plugin'])) { + $this->upgradedPluginFile = $options['hook_extra']['plugin']; + } else { + $this->upgradedPluginFile = null; + } + return $options; + } + + /** + * @access private + * + * @param mixed $input + * @return mixed Returns $input unaltered. + */ + public function clearUpgradedPlugin($input = null) { + $this->upgradedPluginFile = null; + return $input; + } + } + +endif; \ No newline at end of file diff --git a/debug-bar-panel.php b/debug-bar-panel.php index c0ad489..00ef814 100644 --- a/debug-bar-panel.php +++ b/debug-bar-panel.php @@ -6,7 +6,7 @@ if ( !class_exists('PluginUpdateCheckerPanel_3_2', false) && class_exists('Debug * A Debug Bar panel for the plugin update checker. */ class PluginUpdateCheckerPanel_3_2 extends Debug_Bar_Panel { - /** @var PluginUpdateChecker_3_2 */ + /** @var Puc_v4_Plugin_UpdateChecker */ private $updateChecker; private $responseBox = ''; diff --git a/debug-bar-plugin.php b/debug-bar-plugin.php index 9e1e184..d58e48b 100644 --- a/debug-bar-plugin.php +++ b/debug-bar-plugin.php @@ -2,7 +2,7 @@ if ( !class_exists('PucDebugBarPlugin_3_2', false) ) { class PucDebugBarPlugin_3_2 { - /** @var PluginUpdateChecker_3_2 */ + /** @var Puc_v4_Plugin_UpdateChecker */ private $updateChecker; public function __construct($updateChecker) { diff --git a/plugin-update-checker.php b/plugin-update-checker.php index aaf86d9..18db340 100644 --- a/plugin-update-checker.php +++ b/plugin-update-checker.php @@ -1,1568 +1,14 @@ metadataUrl = $metadataUrl; - $this->pluginAbsolutePath = $pluginFile; - $this->pluginFile = plugin_basename($this->pluginAbsolutePath); - $this->muPluginFile = $muPluginFile; - $this->slug = $slug; - $this->optionName = $optionName; - $this->debugMode = (bool)(constant('WP_DEBUG')); - - //If no slug is specified, use the name of the main plugin file as the slug. - //For example, 'my-cool-plugin/cool-plugin.php' becomes 'cool-plugin'. - if ( empty($this->slug) ){ - $this->slug = basename($this->pluginFile, '.php'); - } - - //Plugin slugs must be unique. - $slugCheckFilter = 'puc_is_slug_in_use-' . $this->slug; - $slugUsedBy = apply_filters($slugCheckFilter, false); - if ( $slugUsedBy ) { - $this->triggerError(sprintf( - 'Plugin slug "%s" is already in use by %s. Slugs must be unique.', - htmlentities($this->slug), - htmlentities($slugUsedBy) - ), E_USER_ERROR); - } - add_filter($slugCheckFilter, array($this, 'getAbsolutePath')); - - - if ( empty($this->optionName) ){ - $this->optionName = 'external_updates-' . $this->slug; - } - - //Backwards compatibility: If the plugin is a mu-plugin but no $muPluginFile is specified, assume - //it's the same as $pluginFile given that it's not in a subdirectory (WP only looks in the base dir). - if ( (strpbrk($this->pluginFile, '/\\') === false) && $this->isUnknownMuPlugin() ) { - $this->muPluginFile = $this->pluginFile; - } - - $this->scheduler = $this->createScheduler($checkPeriod); - $this->upgraderStatus = new PucUpgraderStatus_3_2(); - - $this->installHooks(); - } - - /** - * Create an instance of the scheduler. - * - * This is implemented as a method to make it possible for plugins to subclass the update checker - * and substitute their own scheduler. - * - * @param int $checkPeriod - * @return PucScheduler_3_2 - */ - protected function createScheduler($checkPeriod) { - return new PucScheduler_3_2($this, $checkPeriod); - } - - /** - * Install the hooks required to run periodic update checks and inject update info - * into WP data structures. - * - * @return void - */ - protected function installHooks(){ - //Override requests for plugin information - add_filter('plugins_api', array($this, 'injectInfo'), 20, 3); - - //Insert our update info into the update array maintained by WP. - add_filter('site_transient_update_plugins', array($this,'injectUpdate')); //WP 3.0+ - add_filter('transient_update_plugins', array($this,'injectUpdate')); //WP 2.8+ - add_filter('site_transient_update_plugins', array($this, 'injectTranslationUpdates')); - - add_filter('plugin_row_meta', array($this, 'addCheckForUpdatesLink'), 10, 2); - add_action('admin_init', array($this, 'handleManualCheck')); - add_action('all_admin_notices', array($this, 'displayManualCheckResult')); - - //Clear the version number cache when something - anything - is upgraded or WP clears the update cache. - add_filter('upgrader_post_install', array($this, 'clearCachedVersion')); - add_action('delete_site_transient_update_plugins', array($this, 'clearCachedVersion')); - //Clear translation updates when WP clears the update cache. - //This needs to be done directly because the library doesn't actually remove obsolete plugin updates, - //it just hides them (see getUpdate()). We can't do that with translations - too much disk I/O. - add_action('delete_site_transient_update_plugins', array($this, 'clearCachedTranslationUpdates')); - - if ( did_action('plugins_loaded') ) { - $this->initDebugBarPanel(); - } else { - add_action('plugins_loaded', array($this, 'initDebugBarPanel')); - } - - //Rename the update directory to be the same as the existing directory. - add_filter('upgrader_source_selection', array($this, 'fixDirectoryName'), 10, 3); - - //Enable language support (i18n). - load_plugin_textdomain('plugin-update-checker', false, plugin_basename(dirname(__FILE__)) . '/languages'); - - //Allow HTTP requests to the metadata URL even if it's on a local host. - $this->metadataHost = @parse_url($this->metadataUrl, PHP_URL_HOST); - add_filter('http_request_host_is_external', array($this, 'allowMetadataHost'), 10, 2); - } - - /** - * Explicitly allow HTTP requests to the metadata URL. - * - * WordPress has a security feature where the HTTP API will reject all requests that are sent to - * another site hosted on the same server as the current site (IP match), a local host, or a local - * IP, unless the host exactly matches the current site. - * - * This feature is opt-in (at least in WP 4.4). Apparently some people enable it. - * - * That can be a problem when you're developing your plugin and you decide to host the update information - * on the same server as your test site. Update requests will mysteriously fail. - * - * We fix that by adding an exception for the metadata host. - * - * @param bool $allow - * @param string $host - * @return bool - */ - public function allowMetadataHost($allow, $host) { - if ( strtolower($host) === strtolower($this->metadataHost) ) { - return true; - } - return $allow; - } - - /** - * Retrieve plugin info from the configured API endpoint. - * - * @uses wp_remote_get() - * - * @param array $queryArgs Additional query arguments to append to the request. Optional. - * @return PluginInfo_3_2 - */ - public function requestInfo($queryArgs = array()){ - //Query args to append to the URL. Plugins can add their own by using a filter callback (see addQueryArgFilter()). - $installedVersion = $this->getInstalledVersion(); - $queryArgs['installed_version'] = ($installedVersion !== null) ? $installedVersion : ''; - $queryArgs = apply_filters('puc_request_info_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('puc_request_info_options-'.$this->slug, $options); - - //The plugin info should be at 'http://your-api.com/url/here/$slug/info.json' - $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); - $pluginInfo = null; - if ( !is_wp_error($status) ){ - $pluginInfo = PluginInfo_3_2::fromJson($result['body']); - if ( $pluginInfo !== null ) { - $pluginInfo->filename = $this->pluginFile; - $pluginInfo->slug = $this->slug; - } - } else { - $this->triggerError( - sprintf('The URL %s does not point to a valid plugin metadata file. ', $url) - . $status->get_error_message(), - E_USER_WARNING - ); - } - - $pluginInfo = apply_filters('puc_request_info_result-'.$this->slug, $pluginInfo, $result); - return $pluginInfo; - } - - /** - * Check if $result is a successful update API response. - * - * @param array|WP_Error $result - * @return true|WP_Error - */ - private 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('puc_no_response_code', 'wp_remote_get() returned an unexpected result.'); - } - - if ( $result['response']['code'] !== 200 ) { - return new WP_Error( - 'puc_unexpected_response_code', - 'HTTP response code is ' . $result['response']['code'] . ' (expected: 200)' - ); - } - - if ( empty($result['body']) ) { - return new WP_Error('puc_empty_response', 'The metadata file appears to be empty.'); - } - - return true; - } - - /** - * Retrieve the latest update (if any) from the configured API endpoint. - * - * @uses PluginUpdateChecker::requestInfo() - * - * @return PluginUpdate_3_2 An instance of PluginUpdate, or NULL when no updates are available. - */ - 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')); - if ( $pluginInfo == null ){ - return null; - } - $update = PluginUpdate_3_2::fromPluginInfo($pluginInfo); - - //Keep only those translation updates that apply to this site. - $update->translations = $this->filterApplicableTranslations($update->translations); - - return $update; - } - - /** - * Filter a list of translation updates and return a new list that contains only updates - * that apply to the current site. - * - * @param array $translations - * @return array - */ - private function filterApplicableTranslations($translations) { - $languages = array_flip(array_values(get_available_languages())); - $installedTranslations = wp_get_installed_translations('plugins'); - if ( isset($installedTranslations[$this->slug]) ) { - $installedTranslations = $installedTranslations[$this->slug]; - } else { - $installedTranslations = array(); - } - - $applicableTranslations = array(); - foreach($translations as $translation) { - //Does it match one of the available core languages? - $isApplicable = array_key_exists($translation->language, $languages); - //Is it more recent than an already-installed translation? - if ( isset($installedTranslations[$translation->language]) ) { - $updateTimestamp = strtotime($translation->updated); - $installedTimestamp = strtotime($installedTranslations[$translation->language]['PO-Revision-Date']); - $isApplicable = $updateTimestamp > $installedTimestamp; - } - - if ( $isApplicable ) { - $applicableTranslations[] = $translation; - } - } - - return $applicableTranslations; - } - - /** - * Get the currently installed version of the plugin. - * - * @return string Version number. - */ - public function getInstalledVersion(){ - if ( isset($this->cachedInstalledVersion) ) { - return $this->cachedInstalledVersion; - } - - $pluginHeader = $this->getPluginHeader(); - if ( isset($pluginHeader['Version']) ) { - $this->cachedInstalledVersion = $pluginHeader['Version']; - return $pluginHeader['Version']; - } else { - //This can happen if the filename points to something that is not a plugin. - $this->triggerError( - sprintf( - "Can't to read the Version header for '%s'. The filename is incorrect or is not a plugin.", - $this->pluginFile - ), - E_USER_WARNING - ); - return null; - } - } - - /** - * Get plugin's metadata from its file header. - * - * @return array - */ - protected function getPluginHeader() { - if ( !is_file($this->pluginAbsolutePath) ) { - //This can happen if the plugin filename is wrong. - $this->triggerError( - sprintf( - "Can't to read the plugin header for '%s'. The file does not exist.", - $this->pluginFile - ), - E_USER_WARNING - ); - return array(); - } - - if ( !function_exists('get_plugin_data') ){ - /** @noinspection PhpIncludeInspection */ - require_once( ABSPATH . '/wp-admin/includes/plugin.php' ); - } - return get_plugin_data($this->pluginAbsolutePath, false, false); - } - - /** - * Check for plugin updates. - * The results are stored in the DB option specified in $optionName. - * - * @return PluginUpdate_3_2|null - */ - public function checkForUpdates(){ - $installedVersion = $this->getInstalledVersion(); - //Fail silently if we can't find the plugin or read its header. - if ( $installedVersion === null ) { - $this->triggerError( - sprintf('Skipping update check for %s - installed version unknown.', $this->pluginFile), - E_USER_WARNING - ); - return null; - } - - $state = $this->getUpdateState(); - if ( empty($state) ){ - $state = new stdClass; - $state->lastCheck = 0; - $state->checkedVersion = ''; - $state->update = null; - } - - $state->lastCheck = time(); - $state->checkedVersion = $installedVersion; - $this->setUpdateState($state); //Save before checking in case something goes wrong - - $state->update = $this->requestUpdate(); - $this->setUpdateState($state); - - return $this->getUpdate(); - } - - /** - * Load the update checker state from the DB. - * - * @return stdClass|null - */ - public function getUpdateState() { - $state = get_site_option($this->optionName, null); - if ( empty($state) || !is_object($state)) { - $state = null; - } - - if ( isset($state, $state->update) && is_object($state->update) ) { - $state->update = PluginUpdate_3_2::fromObject($state->update); - } - return $state; - } - - - /** - * Persist the update checker state to the DB. - * - * @param StdClass $state - * @return void - */ - private function setUpdateState($state) { - if ( isset($state->update) && is_object($state->update) && method_exists($state->update, 'toStdClass') ) { - $update = $state->update; /** @var PluginUpdate_3_2 $update */ - $state->update = $update->toStdClass(); - } - update_site_option($this->optionName, $state); - } - - /** - * Reset update checker state - i.e. last check time, cached update data and so on. - * - * Call this when your plugin is being uninstalled, or if you want to - * clear the update cache. - */ - public function resetUpdateState() { - delete_site_option($this->optionName); - } - - /** - * Intercept plugins_api() calls that request information about our plugin and - * use the configured API endpoint to satisfy them. - * - * @see plugins_api() - * - * @param mixed $result - * @param string $action - * @param array|object $args - * @return mixed - */ - public function injectInfo($result, $action = null, $args = null){ - $relevant = ($action == 'plugin_information') && isset($args->slug) && ( - ($args->slug == $this->slug) || ($args->slug == dirname($this->pluginFile)) - ); - if ( !$relevant ) { - return $result; - } - - $pluginInfo = $this->requestInfo(); - $pluginInfo = apply_filters('puc_pre_inject_info-' . $this->slug, $pluginInfo); - if ( $pluginInfo ) { - return $pluginInfo->toWpFormat(); - } - - return $result; - } - - /** - * Insert the latest update (if any) into the update list maintained by WP. - * - * @param StdClass $updates Update list. - * @return StdClass Modified update list. - */ - public function injectUpdate($updates){ - //Is there an update to insert? - $update = $this->getUpdate(); - - //No update notifications for mu-plugins unless explicitly enabled. The MU plugin file - //is usually different from the main plugin file so the update wouldn't show up properly anyway. - if ( $this->isUnknownMuPlugin() ) { - $update = null; - } - - if ( !empty($update) ) { - //Let plugins filter the update info before it's passed on to WordPress. - $update = apply_filters('puc_pre_inject_update-' . $this->slug, $update); - $updates = $this->addUpdateToList($updates, $update); - } else { - //Clean up any stale update info. - $updates = $this->removeUpdateFromList($updates); - } - - return $updates; - } - - /** - * @param StdClass|null $updates - * @param PluginUpdate_3_2 $updateToAdd - * @return StdClass - */ - private function addUpdateToList($updates, $updateToAdd) { - if ( !is_object($updates) ) { - $updates = new stdClass(); - $updates->response = array(); - } - - $wpUpdate = $updateToAdd->toWpFormat(); - $pluginFile = $this->pluginFile; - - if ( $this->isMuPlugin() ) { - //WP does not support automatic update installation for mu-plugins, but we can still display a notice. - $wpUpdate->package = null; - $pluginFile = $this->muPluginFile; - } - $updates->response[$pluginFile] = $wpUpdate; - return $updates; - } - - /** - * @param stdClass|null $updates - * @return stdClass|null - */ - private function removeUpdateFromList($updates) { - if ( isset($updates, $updates->response) ) { - unset($updates->response[$this->pluginFile]); - if ( !empty($this->muPluginFile) ) { - unset($updates->response[$this->muPluginFile]); - } - } - return $updates; - } - - /** - * Insert translation updates into the list maintained by WordPress. - * - * @param stdClass $updates - * @return stdClass - */ - public function injectTranslationUpdates($updates) { - $translationUpdates = $this->getTranslationUpdates(); - if ( empty($translationUpdates) ) { - return $updates; - } - - //Being defensive. - if ( !is_object($updates) ) { - $updates = new stdClass(); - } - if ( !isset($updates->translations) ) { - $updates->translations = array(); - } - - //In case there's a name collision with a plugin hosted on wordpress.org, - //remove any preexisting updates that match our plugin. - $translationType = 'plugin'; - $filteredTranslations = array(); - foreach($updates->translations as $translation) { - if ( ($translation['type'] === $translationType) && ($translation['slug'] === $this->slug) ) { - continue; - } - $filteredTranslations[] = $translation; - } - $updates->translations = $filteredTranslations; - - //Add our updates to the list. - foreach($translationUpdates as $update) { - $convertedUpdate = array_merge( - array( - 'type' => $translationType, - 'slug' => $this->slug, - 'autoupdate' => 0, - //AFAICT, WordPress doesn't actually use the "version" field for anything. - //But lets make sure it's there, just in case. - 'version' => isset($update->version) ? $update->version : ('1.' . strtotime($update->updated)), - ), - (array)$update - ); - - $updates->translations[] = $convertedUpdate; - } - - return $updates; - } - - /** - * Rename the update directory to match the existing plugin directory. - * - * When WordPress installs a plugin or theme update, it assumes that the ZIP file will contain - * exactly one directory, and that the directory name will be the same as the directory where - * the plugin/theme is currently installed. - * - * GitHub and other repositories provide ZIP downloads, but they often use directory names like - * "project-branch" or "project-tag-hash". We need to change the name to the actual plugin folder. - * - * This is a hook callback. Don't call it from a plugin. - * - * @param string $source The directory to copy to /wp-content/plugins. Usually a subdirectory of $remoteSource. - * @param string $remoteSource WordPress has extracted the update to this directory. - * @param WP_Upgrader $upgrader - * @return string|WP_Error - */ - public function fixDirectoryName($source, $remoteSource, $upgrader) { - global $wp_filesystem; /** @var WP_Filesystem_Base $wp_filesystem */ - - //Basic sanity checks. - if ( !isset($source, $remoteSource, $upgrader, $upgrader->skin, $wp_filesystem) ) { - return $source; - } - - //If WordPress is upgrading anything other than our plugin, leave the directory name unchanged. - if ( !$this->isPluginBeingUpgraded($upgrader) ) { - return $source; - } - - //Rename the source to match the existing plugin directory. - $pluginDirectoryName = dirname($this->pluginFile); - if ( $pluginDirectoryName === '.' ) { - return $source; - } - $correctedSource = trailingslashit($remoteSource) . $pluginDirectoryName . '/'; - if ( $source !== $correctedSource ) { - //The update archive should contain a single directory that contains the rest of plugin files. Otherwise, - //WordPress will try to copy the entire working directory ($source == $remoteSource). We can't rename - //$remoteSource because that would break WordPress code that cleans up temporary files after update. - if ( $this->isBadDirectoryStructure($remoteSource) ) { - return new WP_Error( - 'puc-incorrect-directory-structure', - sprintf( - 'The directory structure of the update is incorrect. All plugin files should be inside ' . - 'a directory named %s, not at the root of the ZIP file.', - htmlentities($this->slug) - ) - ); - } - - /** @var WP_Upgrader_Skin $upgrader->skin */ - $upgrader->skin->feedback(sprintf( - 'Renaming %s to %s…', - '' . basename($source) . '', - '' . $pluginDirectoryName . '' - )); - - if ( $wp_filesystem->move($source, $correctedSource, true) ) { - $upgrader->skin->feedback('Plugin directory successfully renamed.'); - return $correctedSource; - } else { - return new WP_Error( - 'puc-rename-failed', - 'Unable to rename the update to match the existing plugin directory.' - ); - } - } - - return $source; - } - - /** - * Check for incorrect update directory structure. An update must contain a single directory, - * all other files should be inside that directory. - * - * @param string $remoteSource Directory path. - * @return bool - */ - private function isBadDirectoryStructure($remoteSource) { - global $wp_filesystem; /** @var WP_Filesystem_Base $wp_filesystem */ - - $sourceFiles = $wp_filesystem->dirlist($remoteSource); - if ( is_array($sourceFiles) ) { - $sourceFiles = array_keys($sourceFiles); - $firstFilePath = trailingslashit($remoteSource) . $sourceFiles[0]; - return (count($sourceFiles) > 1) || (!$wp_filesystem->is_dir($firstFilePath)); - } - - //Assume it's fine. - return false; - } - - /** - * Is there and update being installed RIGHT NOW, for this specific plugin? - * - * @param WP_Upgrader|null $upgrader The upgrader that's performing the current update. - * @return bool - */ - public function isPluginBeingUpgraded($upgrader = null) { - return $this->upgraderStatus->isPluginBeingUpgraded($this->pluginFile, $upgrader); - } - - /** - * Get the details of the currently available update, if any. - * - * If no updates are available, or if the last known update version is below or equal - * to the currently installed version, this method will return NULL. - * - * Uses cached update data. To retrieve update information straight from - * the metadata URL, call requestUpdate() instead. - * - * @return PluginUpdate_3_2|null - */ - public function getUpdate() { - $state = $this->getUpdateState(); /** @var StdClass $state */ - - //Is there an update available? - if ( isset($state, $state->update) ) { - $update = $state->update; - //Check if the update is actually newer than the currently installed version. - $installedVersion = $this->getInstalledVersion(); - if ( ($installedVersion !== null) && version_compare($update->version, $installedVersion, '>') ){ - $update->filename = $this->pluginFile; - return $update; - } - } - return null; - } - - /** - * Get a list of available translation updates. - * - * This method will return an empty array if there are no updates. - * Uses cached update data. - * - * @return array - */ - public function getTranslationUpdates() { - $state = $this->getUpdateState(); - if ( isset($state, $state->update, $state->update->translations) ) { - return $state->update->translations; - } - return array(); - } - - /** - * Remove all cached translation updates. - * - * @see wp_clean_update_cache - */ - public function clearCachedTranslationUpdates() { - $state = $this->getUpdateState(); - if ( isset($state, $state->update, $state->update->translations) ) { - $state->update->translations = array(); - $this->setUpdateState($state); - } - } - - /** - * Add a "Check for updates" link to the plugin row in the "Plugins" page. By default, - * the new link will appear after the "Visit plugin site" link. - * - * You can change the link text by using the "puc_manual_check_link-$slug" filter. - * Returning an empty string from the filter will disable the link. - * - * @param array $pluginMeta Array of meta links. - * @param string $pluginFile - * @return array - */ - public function addCheckForUpdatesLink($pluginMeta, $pluginFile) { - $isRelevant = ($pluginFile == $this->pluginFile) - || (!empty($this->muPluginFile) && $pluginFile == $this->muPluginFile); - - if ( $isRelevant && current_user_can('update_plugins') ) { - $linkUrl = wp_nonce_url( - add_query_arg( - array( - 'puc_check_for_updates' => 1, - 'puc_slug' => $this->slug, - ), - self_admin_url('plugins.php') - ), - 'puc_check_for_updates' - ); - - $linkText = apply_filters('puc_manual_check_link-' . $this->slug, __('Check for updates', 'plugin-update-checker')); - if ( !empty($linkText) ) { - $pluginMeta[] = sprintf('%s', esc_attr($linkUrl), $linkText); - } - } - return $pluginMeta; - } - - /** - * Check for updates when the user clicks the "Check for updates" link. - * @see self::addCheckForUpdatesLink() - * - * @return void - */ - public function handleManualCheck() { - $shouldCheck = - isset($_GET['puc_check_for_updates'], $_GET['puc_slug']) - && $_GET['puc_slug'] == $this->slug - && current_user_can('update_plugins') - && check_admin_referer('puc_check_for_updates'); - - if ( $shouldCheck ) { - $update = $this->checkForUpdates(); - $status = ($update === null) ? 'no_update' : 'update_available'; - wp_redirect(add_query_arg( - array( - 'puc_update_check_result' => $status, - 'puc_slug' => $this->slug, - ), - self_admin_url('plugins.php') - )); - } - } - - /** - * Display the results of a manual update check. - * @see self::handleManualCheck() - * - * You can change the result message by using the "puc_manual_check_message-$slug" filter. - */ - public function displayManualCheckResult() { - if ( isset($_GET['puc_update_check_result'], $_GET['puc_slug']) && ($_GET['puc_slug'] == $this->slug) ) { - $status = strval($_GET['puc_update_check_result']); - if ( $status == 'no_update' ) { - $message = __('This plugin is up to date.', 'plugin-update-checker'); - } else if ( $status == 'update_available' ) { - $message = __('A new version of this plugin is available.', 'plugin-update-checker'); - } else { - $message = sprintf(__('Unknown update checker status "%s"', 'plugin-update-checker'), htmlentities($status)); - } - printf( - '

%s

', - apply_filters('puc_manual_check_message-' . $this->slug, $message, $status) - ); - } - } - - /** - * Check if the plugin file is inside the mu-plugins directory. - * - * @return bool - */ - protected function isMuPlugin() { - static $cachedResult = null; - - if ( $cachedResult === null ) { - //Convert both paths to the canonical form before comparison. - $muPluginDir = realpath(WPMU_PLUGIN_DIR); - $pluginPath = realpath($this->pluginAbsolutePath); - - $cachedResult = (strpos($pluginPath, $muPluginDir) === 0); - } - - return $cachedResult; - } - - /** - * MU plugins are partially supported, but only when we know which file in mu-plugins - * corresponds to this plugin. - * - * @return bool - */ - protected function isUnknownMuPlugin() { - return empty($this->muPluginFile) && $this->isMuPlugin(); - } - - /** - * Clear the cached plugin version. This method can be set up as a filter (hook) and will - * return the filter argument unmodified. - * - * @param mixed $filterArgument - * @return mixed - */ - public function clearCachedVersion($filterArgument = null) { - $this->cachedInstalledVersion = null; - return $filterArgument; - } - - /** - * Get absolute path to the main plugin file. - * - * @return string - */ - public function getAbsolutePath() { - return $this->pluginAbsolutePath; - } - - /** - * Register a callback for filtering query arguments. - * - * The callback function should take one argument - an associative array of query arguments. - * It should return a modified array of query arguments. - * - * @uses add_filter() This method is a convenience wrapper for add_filter(). - * - * @param callable $callback - * @return void - */ - public function addQueryArgFilter($callback){ - add_filter('puc_request_info_query_args-'.$this->slug, $callback); - } - - /** - * Register a callback for filtering arguments passed to wp_remote_get(). - * - * The callback function should take one argument - an associative array of arguments - - * and return a modified array or arguments. See the WP documentation on wp_remote_get() - * for details on what arguments are available and how they work. - * - * @uses add_filter() This method is a convenience wrapper for add_filter(). - * - * @param callable $callback - * @return void - */ - public function addHttpRequestArgFilter($callback){ - add_filter('puc_request_info_options-'.$this->slug, $callback); - } - - /** - * Register a callback for filtering the plugin info retrieved from the external API. - * - * The callback function should take two arguments. If the plugin info was retrieved - * successfully, the first argument passed will be an instance of PluginInfo. Otherwise, - * it will be NULL. The second argument will be the corresponding return value of - * wp_remote_get (see WP docs for details). - * - * The callback function should return a new or modified instance of PluginInfo or NULL. - * - * @uses add_filter() This method is a convenience wrapper for add_filter(). - * - * @param callable $callback - * @return void - */ - public function addResultFilter($callback){ - add_filter('puc_request_info_result-'.$this->slug, $callback, 10, 2); - } - - /** - * Register a callback for one of the update checker filters. - * - * Identical to add_filter(), except it automatically adds the "puc_" prefix - * and the "-$plugin_slug" suffix to the filter name. For example, "request_info_result" - * becomes "puc_request_info_result-your_plugin_slug". - * - * @param string $tag - * @param callable $callback - * @param int $priority - * @param int $acceptedArgs - */ - public function addFilter($tag, $callback, $priority = 10, $acceptedArgs = 1) { - add_filter('puc_' . $tag . '-' . $this->slug, $callback, $priority, $acceptedArgs); - } - - /** - * Initialize the update checker Debug Bar plugin/add-on thingy. - */ - public function initDebugBarPanel() { - $debugBarPlugin = dirname(__FILE__) . '/debug-bar-plugin.php'; - if ( class_exists('Debug_Bar', false) && file_exists($debugBarPlugin) ) { - /** @noinspection PhpIncludeInspection */ - require_once $debugBarPlugin; - $this->debugBarPlugin = new PucDebugBarPlugin_3_2($this); - } - } - - /** - * 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; - -if ( !class_exists('PluginInfo_3_2', false) ): - -/** - * A container class for holding and transforming various plugin metadata. - * - * @author Janis Elsts - * @copyright 2016 - * @version 3.2 - * @access public - */ -class PluginInfo_3_2 { - //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; - public $slug; - public $version; - public $homepage; - public $sections = array(); - public $banners; - public $translations = array(); - public $download_url; - - public $author; - public $author_homepage; - - public $requires; - public $tested; - public $upgrade_notice; - - public $rating; - public $num_ratings; - public $downloaded; - public $active_installs; - public $last_updated; - - public $id = 0; //The native WP.org API returns numeric plugin IDs, but they're not used for anything. - - public $filename; //Plugin filename relative to the plugins directory. - - /** - * Create a new instance of PluginInfo from JSON-encoded plugin info - * returned by an external update API. - * - * @param string $json Valid JSON string representing plugin info. - * @return PluginInfo_3_2|null New instance of PluginInfo, 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; - } - - $valid = self::validateMetadata($apiResponse); - if ( is_wp_error($valid) ){ - trigger_error($valid->get_error_message(), E_USER_NOTICE); - 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; - - return $info; - } - - /** - * Very, very basic validation. - * - * @param StdClass $apiResponse - * @return bool|WP_Error - */ - protected static function validateMetadata($apiResponse) { - if ( - !isset($apiResponse->name, $apiResponse->version) - || empty($apiResponse->name) - || empty($apiResponse->version) - ) { - return new WP_Error( - 'puc-invalid-metadata', - "The plugin metadata file does not contain the required 'name' and/or 'version' keys." - ); - } - return true; - } - - - /** - * Transform plugin info into the format used by the native WordPress.org API - * - * @return object - */ - public function toWpFormat(){ - $info = new stdClass; - - //The custom update API is built so that many fields have the same name and format - //as those returned by the native WordPress.org API. These can be assigned directly. - $sameFormat = array( - 'name', 'slug', 'version', 'requires', 'tested', 'rating', 'upgrade_notice', - 'num_ratings', 'downloaded', 'active_installs', 'homepage', 'last_updated', - ); - foreach($sameFormat as $field){ - if ( isset($this->$field) ) { - $info->$field = $this->$field; - } else { - $info->$field = null; - } - } - - //Other fields need to be renamed and/or transformed. - $info->download_link = $this->download_url; - $info->author = $this->getFormattedAuthor(); - $info->sections = array_merge(array('description' => ''), $this->sections); - - if ( !empty($this->banners) ) { - //WP expects an array with two keys: "high" and "low". Both are optional. - //Docs: https://wordpress.org/plugins/about/faq/#banners - $info->banners = is_object($this->banners) ? get_object_vars($this->banners) : $this->banners; - $info->banners = array_intersect_key($info->banners, array('high' => true, 'low' => true)); - } - - return $info; - } - - protected function getFormattedAuthor() { - if ( !empty($this->author_homepage) ){ - return sprintf('%s', $this->author_homepage, $this->author); - } - return $this->author; - } -} - -endif; - -if ( !class_exists('PluginUpdate_3_2', false) ): - -/** - * A simple container class for holding information about an available update. - * - * @author Janis Elsts - * @copyright 2016 - * @version 3.2 - * @access public - */ -class PluginUpdate_3_2 { - 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' - ); - - /** - * Create a new instance of PluginUpdate from its JSON-encoded representation. - * - * @param string $json - * @return PluginUpdate_3_2|null - */ - public static function fromJson($json){ - //Since update-related information is simply a subset of the full plugin info, - //we can parse the update JSON as if it was a plugin info string, then copy over - //the parts that we care about. - $pluginInfo = PluginInfo_3_2::fromJson($json); - if ( $pluginInfo != null ) { - return self::fromPluginInfo($pluginInfo); - } else { - return null; - } - } - - /** - * Create a new instance of PluginUpdate based on an instance of PluginInfo. - * Basically, this just copies a subset of fields from one object to another. - * - * @param PluginInfo_3_2 $info - * @return PluginUpdate_3_2 - */ - public static function fromPluginInfo($info){ - return self::fromObject($info); - } - - /** - * Create a new instance of PluginUpdate by copying the necessary fields from - * another object. - * - * @param StdClass|PluginInfo_3_2|PluginUpdate_3_2 $object The source object. - * @return PluginUpdate_3_2 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; - } - } - 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 - */ - 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; - } - - - /** - * Transform the update into the format used by WordPress native plugin API. - * - * @return object - */ - public function toWpFormat(){ - $update = new stdClass; - - $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; - - if ( !empty($this->upgrade_notice) ){ - $update->upgrade_notice = $this->upgrade_notice; - } - - return $update; - } -} - -endif; - -if ( !class_exists('PucScheduler_3_2', false) ): - -/** - * The scheduler decides when and how often to check for updates. - * It calls @see PluginUpdateChecker::checkForUpdates() to perform the actual checks. - * - * @version 3.2 - */ -class PucScheduler_3_2 { - public $checkPeriod = 12; //How often to check for updates (in hours). - public $throttleRedundantChecks = false; //Check less often if we already know that an update is available. - public $throttledCheckPeriod = 72; - - /** - * @var PluginUpdateChecker_3_2 - */ - protected $updateChecker; - - private $cronHook = null; - - /** - * Scheduler constructor. - * - * @param PluginUpdateChecker_3_2 $updateChecker - * @param int $checkPeriod How often to check for updates (in hours). - */ - public function __construct($updateChecker, $checkPeriod) { - $this->updateChecker = $updateChecker; - $this->checkPeriod = $checkPeriod; - - //Set up the periodic update checks - $this->cronHook = 'check_plugin_updates-' . $this->updateChecker->slug; - if ( $this->checkPeriod > 0 ){ - - //Trigger the check via Cron. - //Try to use one of the default schedules if possible as it's less likely to conflict - //with other plugins and their custom schedules. - $defaultSchedules = array( - 1 => 'hourly', - 12 => 'twicedaily', - 24 => 'daily', - ); - if ( array_key_exists($this->checkPeriod, $defaultSchedules) ) { - $scheduleName = $defaultSchedules[$this->checkPeriod]; - } else { - //Use a custom cron schedule. - $scheduleName = 'every' . $this->checkPeriod . 'hours'; - add_filter('cron_schedules', array($this, '_addCustomSchedule')); - } - - if ( !wp_next_scheduled($this->cronHook) && !defined('WP_INSTALLING') ) { - wp_schedule_event(time(), $scheduleName, $this->cronHook); - } - add_action($this->cronHook, array($this, 'maybeCheckForUpdates')); - - register_deactivation_hook($this->updateChecker->pluginFile, array($this, '_removeUpdaterCron')); - - //In case Cron is disabled or unreliable, we also manually trigger - //the periodic checks while the user is browsing the Dashboard. - add_action( 'admin_init', array($this, 'maybeCheckForUpdates') ); - - //Like WordPress itself, we check more often on certain pages. - /** @see wp_update_plugins */ - add_action('load-update-core.php', array($this, 'maybeCheckForUpdates')); - add_action('load-plugins.php', array($this, 'maybeCheckForUpdates')); - add_action('load-update.php', array($this, 'maybeCheckForUpdates')); - //This hook fires after a bulk update is complete. - add_action('upgrader_process_complete', array($this, 'maybeCheckForUpdates'), 11, 0); - - } else { - //Periodic checks are disabled. - wp_clear_scheduled_hook($this->cronHook); - } - } - - /** - * Check for updates if the configured check interval has already elapsed. - * Will use a shorter check interval on certain admin pages like "Dashboard -> Updates" or when doing cron. - * - * You can override the default behaviour by using the "puc_check_now-$slug" filter. - * The filter callback will be passed three parameters: - * - Current decision. TRUE = check updates now, FALSE = don't check now. - * - Last check time as a Unix timestamp. - * - Configured check period in hours. - * Return TRUE to check for updates immediately, or FALSE to cancel. - * - * This method is declared public because it's a hook callback. Calling it directly is not recommended. - */ - public function maybeCheckForUpdates(){ - if ( empty($this->checkPeriod) ){ - return; - } - - $state = $this->updateChecker->getUpdateState(); - $shouldCheck = - empty($state) || - !isset($state->lastCheck) || - ( (time() - $state->lastCheck) >= $this->getEffectiveCheckPeriod() ); - - //Let plugin authors substitute their own algorithm. - $shouldCheck = apply_filters( - 'puc_check_now-' . $this->updateChecker->slug, - $shouldCheck, - (!empty($state) && isset($state->lastCheck)) ? $state->lastCheck : 0, - $this->checkPeriod - ); - - if ( $shouldCheck ) { - $this->updateChecker->checkForUpdates(); - } - } - - /** - * Calculate the actual check period based on the current status and environment. - * - * @return int Check period in seconds. - */ - protected function getEffectiveCheckPeriod() { - $currentFilter = current_filter(); - if ( in_array($currentFilter, array('load-update-core.php', 'upgrader_process_complete')) ) { - //Check more often when the user visits "Dashboard -> Updates" or does a bulk update. - $period = 60; - } else if ( in_array($currentFilter, array('load-plugins.php', 'load-update.php')) ) { - //Also check more often on the "Plugins" page and /wp-admin/update.php. - $period = 3600; - } else if ( $this->throttleRedundantChecks && ($this->updateChecker->getUpdate() !== null) ) { - //Check less frequently if it's already known that an update is available. - $period = $this->throttledCheckPeriod * 3600; - } else if ( defined('DOING_CRON') && constant('DOING_CRON') ) { - //WordPress cron schedules are not exact, so lets do an update check even - //if slightly less than $checkPeriod hours have elapsed since the last check. - $cronFuzziness = 20 * 60; - $period = $this->checkPeriod * 3600 - $cronFuzziness; - } else { - $period = $this->checkPeriod * 3600; - } - - return $period; - } - - /** - * Add our custom schedule to the array of Cron schedules used by WP. - * - * @param array $schedules - * @return array - */ - public function _addCustomSchedule($schedules){ - if ( $this->checkPeriod && ($this->checkPeriod > 0) ){ - $scheduleName = 'every' . $this->checkPeriod . 'hours'; - $schedules[$scheduleName] = array( - 'interval' => $this->checkPeriod * 3600, - 'display' => sprintf('Every %d hours', $this->checkPeriod), - ); - } - return $schedules; - } - - /** - * Remove the scheduled cron event that the library uses to check for updates. - * - * @return void - */ - public function _removeUpdaterCron(){ - wp_clear_scheduled_hook($this->cronHook); - } - - /** - * Get the name of the update checker's WP-cron hook. Mostly useful for debugging. - * - * @return string - */ - public function getCronHookName() { - return $this->cronHook; - } -} - -endif; - - -if ( !class_exists('PucUpgraderStatus_3_2', false) ): - -/** - * A utility class that helps figure out which plugin WordPress is upgrading. - * - * It may seem strange to have an separate class just for that, but the task is surprisingly complicated. - * Core classes like Plugin_Upgrader don't expose the plugin file name during an in-progress update (AFAICT). - * This class uses a few workarounds and heuristics to get the file name. - */ -class PucUpgraderStatus_3_2 { - private $upgradedPluginFile = null; //The plugin that is currently being upgraded by WordPress. - - public function __construct() { - //Keep track of which plugin WordPress is currently upgrading. - add_filter('upgrader_pre_install', array($this, 'setUpgradedPlugin'), 10, 2); - add_filter('upgrader_package_options', array($this, 'setUpgradedPluginFromOptions'), 10, 1); - add_filter('upgrader_post_install', array($this, 'clearUpgradedPlugin'), 10, 1); - add_action('upgrader_process_complete', array($this, 'clearUpgradedPlugin'), 10, 1); - } - - /** - * Is there and update being installed RIGHT NOW, for a specific plugin? - * - * Caution: This method is unreliable. WordPress doesn't make it easy to figure out what it is upgrading, - * and upgrader implementations are liable to change without notice. - * - * @param string $pluginFile The plugin to check. - * @param WP_Upgrader|null $upgrader The upgrader that's performing the current update. - * @return bool True if the plugin identified by $pluginFile is being upgraded. - */ - public function isPluginBeingUpgraded($pluginFile, $upgrader = null) { - if ( isset($upgrader) ) { - $upgradedPluginFile = $this->getPluginBeingUpgradedBy($upgrader); - if ( !empty($upgradedPluginFile) ) { - $this->upgradedPluginFile = $upgradedPluginFile; - } - } - return ( !empty($this->upgradedPluginFile) && ($this->upgradedPluginFile === $pluginFile) ); - } - - /** - * Get the file name of the plugin that's currently being upgraded. - * - * @param Plugin_Upgrader|WP_Upgrader $upgrader - * @return string|null - */ - private function getPluginBeingUpgradedBy($upgrader) { - if ( !isset($upgrader, $upgrader->skin) ) { - return null; - } - - //Figure out which plugin is being upgraded. - $pluginFile = null; - $skin = $upgrader->skin; - if ( $skin instanceof Plugin_Upgrader_Skin ) { - if ( isset($skin->plugin) && is_string($skin->plugin) && ($skin->plugin !== '') ) { - $pluginFile = $skin->plugin; - } - } elseif ( isset($skin->plugin_info) && is_array($skin->plugin_info) ) { - //This case is tricky because Bulk_Plugin_Upgrader_Skin (etc) doesn't actually store the plugin - //filename anywhere. Instead, it has the plugin headers in $plugin_info. So the best we can - //do is compare those headers to the headers of installed plugins. - $pluginFile = $this->identifyPluginByHeaders($skin->plugin_info); - } - - return $pluginFile; - } - - /** - * Identify an installed plugin based on its headers. - * - * @param array $searchHeaders The plugin file header to look for. - * @return string|null Plugin basename ("foo/bar.php"), or NULL if we can't identify the plugin. - */ - private function identifyPluginByHeaders($searchHeaders) { - if ( !function_exists('get_plugins') ){ - /** @noinspection PhpIncludeInspection */ - require_once( ABSPATH . '/wp-admin/includes/plugin.php' ); - } - - $installedPlugins = get_plugins(); - $matches = array(); - foreach($installedPlugins as $pluginBasename => $headers) { - $diff1 = array_diff_assoc($headers, $searchHeaders); - $diff2 = array_diff_assoc($searchHeaders, $headers); - if ( empty($diff1) && empty($diff2) ) { - $matches[] = $pluginBasename; - } - } - - //It's possible (though very unlikely) that there could be two plugins with identical - //headers. In that case, we can't unambiguously identify the plugin that's being upgraded. - if ( count($matches) !== 1 ) { - return null; - } - - return reset($matches); - } - - /** - * @access private - * - * @param mixed $input - * @param array $hookExtra - * @return mixed Returns $input unaltered. - */ - public function setUpgradedPlugin($input, $hookExtra) { - if (!empty($hookExtra['plugin']) && is_string($hookExtra['plugin'])) { - $this->upgradedPluginFile = $hookExtra['plugin']; - } else { - $this->upgradedPluginFile = null; - } - return $input; - } - - /** - * @access private - * - * @param array $options - * @return array - */ - public function setUpgradedPluginFromOptions($options) { - if (isset($options['hook_extra']['plugin']) && is_string($options['hook_extra']['plugin'])) { - $this->upgradedPluginFile = $options['hook_extra']['plugin']; - } else { - $this->upgradedPluginFile = null; - } - return $options; - } - - /** - * @access private - * - * @param mixed $input - * @return mixed Returns $input unaltered. - */ - public function clearUpgradedPlugin($input = null) { - $this->upgradedPluginFile = null; - return $input; - } -} - -endif; +require dirname(__FILE__) . '/Puc/v4/Autoloader.php'; +new Puc_v4_Autoloader(); if ( !class_exists('PucFactory', false) ): @@ -1594,7 +40,7 @@ class PucFactory { * @param int $checkPeriod * @param string $optionName * @param string $muPluginFile - * @return PluginUpdateChecker_3_2 + * @return Puc_v4_Plugin_UpdateChecker */ public static function buildUpdateChecker($metadataUrl, $pluginFile, $slug = '', $checkPeriod = 12, $optionName = '', $muPluginFile = '') { $class = self::getLatestClassVersion('PluginUpdateChecker'); @@ -1654,10 +100,8 @@ class PucFactory { endif; -require_once(dirname(__FILE__) . '/github-checker.php'); - //Register classes defined in this file with the factory. -PucFactory::addVersion('PluginUpdateChecker', 'PluginUpdateChecker_3_2', '3.2'); -PucFactory::addVersion('PluginUpdate', 'PluginUpdate_3_2', '3.2'); -PucFactory::addVersion('PluginInfo', 'PluginInfo_3_2', '3.2'); -PucFactory::addVersion('PucGitHubChecker', 'PucGitHubChecker_3_2', '3.2'); +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'); From 9effd33bfac88322f7d73130aaa3078e766aaccc Mon Sep 17 00:00:00 2001 From: Yahnis Elsts Date: Mon, 12 Dec 2016 16:26:41 +0200 Subject: [PATCH 02/44] 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'); From 26c24167abd22cc0ec7d06911d69e6f19b3b7f71 Mon Sep 17 00:00:00 2001 From: Yahnis Elsts Date: Wed, 14 Dec 2016 11:18:58 +0200 Subject: [PATCH 03/44] WIP: Theme updates. - Refactor the scheduler so that it can be used in themes. - Add state storage to the base update checker. - Move the "allow metadata host" workaround to the base class. - Rename cron hook from "check_plugin_updates-$slug" to "puc_cron_check_updates-$slug" and "tuc_cron_check_updates-$slug". --- Puc/v4/Plugin/UpdateChecker.php | 168 +++----------------------- Puc/v4/Scheduler.php | 30 +++-- Puc/v4/Theme/UpdateChecker.php | 47 +++++++- Puc/v4/Update.php | 10 ++ Puc/v4/UpdateChecker.php | 208 ++++++++++++++++++++++++++++++++ 5 files changed, 295 insertions(+), 168 deletions(-) diff --git a/Puc/v4/Plugin/UpdateChecker.php b/Puc/v4/Plugin/UpdateChecker.php index 1fae08c..cec550f 100644 --- a/Puc/v4/Plugin/UpdateChecker.php +++ b/Puc/v4/Plugin/UpdateChecker.php @@ -9,23 +9,17 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): * @access public */ class Puc_v4_Plugin_UpdateChecker extends Puc_v4_UpdateChecker { - public $metadataUrl = ''; //The URL of the plugin's metadata file. + protected $updateClass = 'Puc_v4_Plugin_Update'; + 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 $muPluginFile = ''; //For MU plugins, the plugin filename relative to the mu-plugins directory. - //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; - protected $upgraderStatus; private $debugBarPlugin = null; private $cachedInstalledVersion = null; - private $metadataHost = ''; //The host component of $metadataUrl. - /** * Class constructor. * @@ -37,18 +31,14 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): * @param string $muPluginFile Optional. The plugin filename relative to the mu-plugins directory. */ public function __construct($metadataUrl, $pluginFile, $slug = '', $checkPeriod = 12, $optionName = '', $muPluginFile = ''){ - $this->metadataUrl = $metadataUrl; $this->pluginAbsolutePath = $pluginFile; $this->pluginFile = plugin_basename($this->pluginAbsolutePath); $this->muPluginFile = $muPluginFile; - $this->slug = $slug; - $this->optionName = $optionName; - $this->debugMode = (bool)(constant('WP_DEBUG')); //If no slug is specified, use the name of the main plugin file as the slug. //For example, 'my-cool-plugin/cool-plugin.php' becomes 'cool-plugin'. - if ( empty($this->slug) ){ - $this->slug = basename($this->pluginFile, '.php'); + if ( empty($slug) ){ + $slug = basename($this->pluginFile, '.php'); } //Plugin slugs must be unique. @@ -63,21 +53,15 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): } add_filter($slugCheckFilter, array($this, 'getAbsolutePath')); - - if ( empty($this->optionName) ){ - $this->optionName = 'external_updates-' . $this->slug; - } - //Backwards compatibility: If the plugin is a mu-plugin but no $muPluginFile is specified, assume //it's the same as $pluginFile given that it's not in a subdirectory (WP only looks in the base dir). if ( (strpbrk($this->pluginFile, '/\\') === false) && $this->isUnknownMuPlugin() ) { $this->muPluginFile = $this->pluginFile; } - $this->scheduler = $this->createScheduler($checkPeriod); $this->upgraderStatus = new Puc_v4_UpgraderStatus(); - $this->installHooks(); + parent::__construct($metadataUrl, $slug, $checkPeriod, $optionName); } /** @@ -90,7 +74,9 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): * @return Puc_v4_Scheduler */ protected function createScheduler($checkPeriod) { - return new Puc_v4_Scheduler($this, $checkPeriod); + $scheduler = new Puc_v4_Scheduler($this, $checkPeriod, array('load-plugins.php')); + register_deactivation_hook($this->pluginFile, array($scheduler, 'removeUpdaterCron')); + return $scheduler; } /** @@ -130,36 +116,10 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): add_filter('upgrader_source_selection', array($this, 'fixDirectoryName'), 10, 3); //Enable language support (i18n). + //TODO: The directory path has changed. load_plugin_textdomain('plugin-update-checker', false, plugin_basename(dirname(__FILE__)) . '/languages'); - //Allow HTTP requests to the metadata URL even if it's on a local host. - $this->metadataHost = @parse_url($this->metadataUrl, PHP_URL_HOST); - add_filter('http_request_host_is_external', array($this, 'allowMetadataHost'), 10, 2); - } - - /** - * Explicitly allow HTTP requests to the metadata URL. - * - * WordPress has a security feature where the HTTP API will reject all requests that are sent to - * another site hosted on the same server as the current site (IP match), a local host, or a local - * IP, unless the host exactly matches the current site. - * - * This feature is opt-in (at least in WP 4.4). Apparently some people enable it. - * - * That can be a problem when you're developing your plugin and you decide to host the update information - * on the same server as your test site. Update requests will mysteriously fail. - * - * We fix that by adding an exception for the metadata host. - * - * @param bool $allow - * @param string $host - * @return bool - */ - public function allowMetadataHost($allow, $host) { - if ( strtolower($host) === strtolower($this->metadataHost) ) { - return true; - } - return $allow; + parent::installHooks(); } /** @@ -355,82 +315,6 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): return get_plugin_data($this->pluginAbsolutePath, false, false); } - /** - * Check for plugin updates. - * The results are stored in the DB option specified in $optionName. - * - * @return Puc_v4_Plugin_Update|null - */ - public function checkForUpdates(){ - $installedVersion = $this->getInstalledVersion(); - //Fail silently if we can't find the plugin or read its header. - if ( $installedVersion === null ) { - $this->triggerError( - sprintf('Skipping update check for %s - installed version unknown.', $this->pluginFile), - E_USER_WARNING - ); - return null; - } - - $state = $this->getUpdateState(); - if ( empty($state) ){ - $state = new stdClass; - $state->lastCheck = 0; - $state->checkedVersion = ''; - $state->update = null; - } - - $state->lastCheck = time(); - $state->checkedVersion = $installedVersion; - $this->setUpdateState($state); //Save before checking in case something goes wrong - - $state->update = $this->requestUpdate(); - $this->setUpdateState($state); - - return $this->getUpdate(); - } - - /** - * Load the update checker state from the DB. - * - * @return stdClass|null - */ - public function getUpdateState() { - $state = get_site_option($this->optionName, null); - if ( empty($state) || !is_object($state)) { - $state = null; - } - - if ( isset($state, $state->update) && is_object($state->update) ) { - $state->update = Puc_v4_Plugin_Update::fromObject($state->update); - } - return $state; - } - - - /** - * Persist the update checker state to the DB. - * - * @param StdClass $state - * @return void - */ - private function setUpdateState($state) { - if ( isset($state->update) && is_object($state->update) && method_exists($state->update, 'toStdClass') ) { - $update = $state->update; /** @var Puc_v4_Plugin_Update $update */ - $state->update = $update->toStdClass(); - } - update_site_option($this->optionName, $state); - } - - /** - * Reset update checker state - i.e. last check time, cached update data and so on. - * - * Call this when your plugin is being uninstalled, or if you want to - * clear the update cache. - */ - public function resetUpdateState() { - delete_site_option($this->optionName); - } /** * Intercept plugins_api() calls that request information about our plugin and @@ -692,19 +576,11 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): * @return Puc_v4_Plugin_Update|null */ public function getUpdate() { - $state = $this->getUpdateState(); /** @var StdClass $state */ - - //Is there an update available? - if ( isset($state, $state->update) ) { - $update = $state->update; - //Check if the update is actually newer than the currently installed version. - $installedVersion = $this->getInstalledVersion(); - if ( ($installedVersion !== null) && version_compare($update->version, $installedVersion, '>') ){ - $update->filename = $this->pluginFile; - return $update; - } + $update = parent::getUpdate(); + if ( isset($update) ) { + $update->filename = $this->pluginFile; } - return null; + return $update; } /** @@ -930,22 +806,6 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): add_filter('puc_request_info_result-'.$this->slug, $callback, 10, 2); } - /** - * Register a callback for one of the update checker filters. - * - * Identical to add_filter(), except it automatically adds the "puc_" prefix - * and the "-$plugin_slug" suffix to the filter name. For example, "request_info_result" - * becomes "puc_request_info_result-your_plugin_slug". - * - * @param string $tag - * @param callable $callback - * @param int $priority - * @param int $acceptedArgs - */ - public function addFilter($tag, $callback, $priority = 10, $acceptedArgs = 1) { - add_filter('puc_' . $tag . '-' . $this->slug, $callback, $priority, $acceptedArgs); - } - /** * Initialize the update checker Debug Bar plugin/add-on thingy. */ diff --git a/Puc/v4/Scheduler.php b/Puc/v4/Scheduler.php index 160c99d..ee030ed 100644 --- a/Puc/v4/Scheduler.php +++ b/Puc/v4/Scheduler.php @@ -3,15 +3,17 @@ if ( !class_exists('Puc_v4_Scheduler', false) ): /** * The scheduler decides when and how often to check for updates. - * It calls @see Puc_v4_Plugin_UpdateChecker::checkForUpdates() to perform the actual checks. + * It calls @see Puc_v4_UpdateChecker::checkForUpdates() to perform the actual checks. */ class Puc_v4_Scheduler { public $checkPeriod = 12; //How often to check for updates (in hours). public $throttleRedundantChecks = false; //Check less often if we already know that an update is available. public $throttledCheckPeriod = 72; + protected $hourlyCheckHooks = array('load-update.php'); + /** - * @var Puc_v4_Plugin_UpdateChecker + * @var Puc_v4_UpdateChecker */ protected $updateChecker; @@ -20,15 +22,16 @@ if ( !class_exists('Puc_v4_Scheduler', false) ): /** * Scheduler constructor. * - * @param Puc_v4_Plugin_UpdateChecker $updateChecker + * @param Puc_v4_UpdateChecker $updateChecker * @param int $checkPeriod How often to check for updates (in hours). + * @param array $hourlyHooks */ - public function __construct($updateChecker, $checkPeriod) { + public function __construct($updateChecker, $checkPeriod, $hourlyHooks = array('load-plugins.php')) { $this->updateChecker = $updateChecker; $this->checkPeriod = $checkPeriod; //Set up the periodic update checks - $this->cronHook = 'check_plugin_updates-' . $this->updateChecker->slug; + $this->cronHook = $this->updateChecker->getFilterName('cron_check_updates'); if ( $this->checkPeriod > 0 ){ //Trigger the check via Cron. @@ -52,8 +55,6 @@ if ( !class_exists('Puc_v4_Scheduler', false) ): } add_action($this->cronHook, array($this, 'maybeCheckForUpdates')); - register_deactivation_hook($this->updateChecker->pluginFile, array($this, '_removeUpdaterCron')); - //In case Cron is disabled or unreliable, we also manually trigger //the periodic checks while the user is browsing the Dashboard. add_action( 'admin_init', array($this, 'maybeCheckForUpdates') ); @@ -61,8 +62,11 @@ if ( !class_exists('Puc_v4_Scheduler', false) ): //Like WordPress itself, we check more often on certain pages. /** @see wp_update_plugins */ add_action('load-update-core.php', array($this, 'maybeCheckForUpdates')); - add_action('load-plugins.php', array($this, 'maybeCheckForUpdates')); - add_action('load-update.php', array($this, 'maybeCheckForUpdates')); + //"load-update.php" and "load-plugins.php" or "load-themes.php". + $this->hourlyCheckHooks = array_merge($this->hourlyCheckHooks, $hourlyHooks); + foreach($this->hourlyCheckHooks as $hook) { + add_action($hook, array($this, 'maybeCheckForUpdates')); + } //This hook fires after a bulk update is complete. add_action('upgrader_process_complete', array($this, 'maybeCheckForUpdates'), 11, 0); @@ -98,7 +102,7 @@ if ( !class_exists('Puc_v4_Scheduler', false) ): //Let plugin authors substitute their own algorithm. $shouldCheck = apply_filters( - 'puc_check_now-' . $this->updateChecker->slug, + $this->updateChecker->getFilterName('check_now'), $shouldCheck, (!empty($state) && isset($state->lastCheck)) ? $state->lastCheck : 0, $this->checkPeriod @@ -119,8 +123,8 @@ if ( !class_exists('Puc_v4_Scheduler', false) ): if ( in_array($currentFilter, array('load-update-core.php', 'upgrader_process_complete')) ) { //Check more often when the user visits "Dashboard -> Updates" or does a bulk update. $period = 60; - } else if ( in_array($currentFilter, array('load-plugins.php', 'load-update.php')) ) { - //Also check more often on the "Plugins" page and /wp-admin/update.php. + } else if ( in_array($currentFilter, $this->hourlyCheckHooks) ) { + //Also check more often on /wp-admin/update.php and the "Plugins" or "Themes" page. $period = 3600; } else if ( $this->throttleRedundantChecks && ($this->updateChecker->getUpdate() !== null) ) { //Check less frequently if it's already known that an update is available. @@ -159,7 +163,7 @@ if ( !class_exists('Puc_v4_Scheduler', false) ): * * @return void */ - public function _removeUpdaterCron(){ + public function removeUpdaterCron(){ wp_clear_scheduled_hook($this->cronHook); } diff --git a/Puc/v4/Theme/UpdateChecker.php b/Puc/v4/Theme/UpdateChecker.php index cfee138..4e1e1f7 100644 --- a/Puc/v4/Theme/UpdateChecker.php +++ b/Puc/v4/Theme/UpdateChecker.php @@ -4,6 +4,7 @@ if ( class_exists('Puc_v4_Theme_UpdateChecker', false) ): class Puc_v4_Theme_UpdateChecker extends Puc_v4_UpdateChecker { protected $filterPrefix = 'tuc_'; + protected $updateClass = 'Puc_v4_Theme_Update'; /** * @var string Theme directory name. @@ -22,7 +23,39 @@ if ( class_exists('Puc_v4_Theme_UpdateChecker', false) ): $this->stylesheet = $stylesheet; $this->theme = wp_get_theme($this->stylesheet); - parent::__construct($metadataUrl, $customSlug ? $customSlug : $stylesheet); + parent::__construct($metadataUrl, $customSlug ? $customSlug : $stylesheet, $checkPeriod, $optionName); + } + + protected function installHooks() { + parent::installHooks(); + + //Insert our update info into the update list maintained by WP. + add_filter('site_transient_update_themes', array($this,'injectUpdate')); + + //TODO: Rename the update directory to be the same as the existing directory. + //add_filter('upgrader_source_selection', array($this, 'fixDirectoryName'), 10, 3); + } + + /** + * Insert the latest update (if any) into the update list maintained by WP. + * + * @param StdClass $updates Update list. + * @return StdClass Modified update list. + */ + public function injectUpdate($updates) { + //Is there an update to insert? + $update = $this->getUpdate(); + + if ( !empty($update) ) { + //Let themes filter the update info before it's passed on to WordPress. + $update = apply_filters($this->getFilterName('pre_inject_update'), $update); + $updates->response[$this->stylesheet] = $update->toWpFormat(); + } else { + //Clean up any stale update info. + unset($updates->response[$this->stylesheet]); + } + + return $updates; } /** @@ -86,6 +119,18 @@ if ( class_exists('Puc_v4_Theme_UpdateChecker', false) ): public function getInstalledVersion() { return $this->theme->get('Version'); } + + /** + * Create an instance of the scheduler. + * + * @param int $checkPeriod + * @return Puc_v4_Scheduler + */ + protected function createScheduler($checkPeriod) { + return new Puc_v4_Scheduler($this, $checkPeriod, array('load-themes.php')); + } + + //TODO: Various add*filter utilities for backwards compatibility. } endif; \ No newline at end of file diff --git a/Puc/v4/Update.php b/Puc/v4/Update.php index f5a28b5..a2753e8 100644 --- a/Puc/v4/Update.php +++ b/Puc/v4/Update.php @@ -19,6 +19,16 @@ if ( !class_exists('Puc_v4_Update', false) ): protected function getFieldNames() { return array('slug', 'version', 'download_url', 'translations'); } + + public function toWpFormat() { + $update = new stdClass(); + + $update->slug = $this->slug; + $update->new_version = $this->version; + $update->package = $this->download_url; + + return $update; + } } endif; \ No newline at end of file diff --git a/Puc/v4/UpdateChecker.php b/Puc/v4/UpdateChecker.php index 5c0a1d9..322aaca 100644 --- a/Puc/v4/UpdateChecker.php +++ b/Puc/v4/UpdateChecker.php @@ -4,7 +4,13 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): abstract class Puc_v4_UpdateChecker { protected $filterPrefix = 'puc_'; + protected $updateClass = ''; + /** + * Set to TRUE to enable error reporting. Errors are raised using trigger_error() + * and should be logged to the standard PHP error log. + * @var bool + */ public $debugMode = false; /** @@ -22,6 +28,16 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): */ public $slug = ''; + /** + * @var Puc_v4_Scheduler + */ + public $scheduler; + + /** + * @var string The host component of $metadataUrl. + */ + protected $metadataHost = ''; + public function __construct($metadataUrl, $slug, $checkPeriod = 12, $optionName = '') { $this->debugMode = (bool)(constant('WP_DEBUG')); $this->metadataUrl = $metadataUrl; @@ -37,6 +53,169 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): $this->optionName = $this->filterPrefix . 'external_updates-' . $this->slug; } } + + $this->scheduler = $this->createScheduler($checkPeriod); + + $this->loadTextDomain(); + $this->installHooks(); + } + + protected function loadTextDomain() { + //We're not using load_plugin_textdomain() or its siblings because figuring out where + //the library is located (plugin, mu-plugin, theme, custom wp-content paths) is messy. + $domain = 'plugin-update-checker'; + $locale = apply_filters('plugin_locale', is_admin() ? get_user_locale() : get_locale(), $domain); + + $moFile = $domain . '-' . $locale . '.mo'; + $path = realpath(dirname(__FILE__) . '/../../languages'); + + if ($path && file_exists($path)) { + load_textdomain($domain, $path . '/ ' . $moFile); + } + } + + protected function installHooks() { + //TODO: Translation updates + //TODO: Fix directory name + + //Allow HTTP requests to the metadata URL even if it's on a local host. + $this->metadataHost = @parse_url($this->metadataUrl, PHP_URL_HOST); + add_filter('http_request_host_is_external', array($this, 'allowMetadataHost'), 10, 2); + } + + /** + * Explicitly allow HTTP requests to the metadata URL. + * + * WordPress has a security feature where the HTTP API will reject all requests that are sent to + * another site hosted on the same server as the current site (IP match), a local host, or a local + * IP, unless the host exactly matches the current site. + * + * This feature is opt-in (at least in WP 4.4). Apparently some people enable it. + * + * That can be a problem when you're developing your plugin and you decide to host the update information + * on the same server as your test site. Update requests will mysteriously fail. + * + * We fix that by adding an exception for the metadata host. + * + * @param bool $allow + * @param string $host + * @return bool + */ + public function allowMetadataHost($allow, $host) { + if ( strtolower($host) === strtolower($this->metadataHost) ) { + return true; + } + return $allow; + } + + /** + * Create an instance of the scheduler. + * + * @param int $checkPeriod + * @return Puc_v4_Scheduler + */ + abstract protected function createScheduler($checkPeriod); + + /** + * Check for updates. The results are stored in the DB option specified in $optionName. + * + * @return Puc_v4_Update|null + */ + public function checkForUpdates() { + $installedVersion = $this->getInstalledVersion(); + //Fail silently if we can't find the plugin/theme or read its header. + if ( $installedVersion === null ) { + $this->triggerError( + sprintf('Skipping update check for %s - installed version unknown.', $this->slug), + E_USER_WARNING + ); + return null; + } + + $state = $this->getUpdateState(); + if ( empty($state) ) { + $state = new stdClass; + $state->lastCheck = 0; + $state->checkedVersion = ''; + $state->update = null; + } + + $state->lastCheck = time(); + $state->checkedVersion = $installedVersion; + $this->setUpdateState($state); //Save before checking in case something goes wrong + + $state->update = $this->requestUpdate(); + $this->setUpdateState($state); + + return $this->getUpdate(); + } + + /** + * Load the update checker state from the DB. + * + * @return stdClass|null + */ + public function getUpdateState() { + $state = get_site_option($this->optionName, null); + if ( empty($state) || !is_object($state) ) { + $state = null; + } + + if ( isset($state, $state->update) && is_object($state->update) ) { + $state->update = call_user_func(array($this->updateClass, 'fromObject'), $state->update); + } + return $state; + } + + /** + * Persist the update checker state to the DB. + * + * @param StdClass $state + * @return void + */ + protected function setUpdateState($state) { + if (isset($state->update) && is_object($state->update) && method_exists($state->update, 'toStdClass')) { + $update = $state->update; + /** @var Puc_v4_Update $update */ + $state->update = $update->toStdClass(); + } + update_site_option($this->optionName, $state); + } + + /** + * Reset update checker state - i.e. last check time, cached update data and so on. + * + * Call this when your plugin is being uninstalled, or if you want to + * clear the update cache. + */ + public function resetUpdateState() { + delete_site_option($this->optionName); + } + + /** + * Get the details of the currently available update, if any. + * + * If no updates are available, or if the last known update version is below or equal + * to the currently installed version, this method will return NULL. + * + * Uses cached update data. To retrieve update information straight from + * the metadata URL, call requestUpdate() instead. + * + * @return Puc_v4_Update|Puc_v4_Plugin_Update|Puc_v4_Theme_Update|null + */ + public function getUpdate() { + $state = $this->getUpdateState(); /** @var StdClass $state */ + + //Is there an update available? + if ( isset($state, $state->update) ) { + $update = $state->update; + //Check if the update is actually newer than the currently installed version. + $installedVersion = $this->getInstalledVersion(); + if ( ($installedVersion !== null) && version_compare($update->version, $installedVersion, '>') ){ + return $update; + } + } + return null; } /** @@ -85,6 +264,35 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): */ abstract public function getInstalledVersion(); + /** + * Register a callback for one of the update checker filters. + * + * Identical to add_filter(), except it automatically adds the "puc_"/"tuc_" prefix + * and the "-$slug" suffix to the filter name. For example, "request_info_result" + * becomes "puc_request_info_result-your_plugin_slug". + * + * @param string $tag + * @param callable $callback + * @param int $priority + * @param int $acceptedArgs + */ + public function addFilter($tag, $callback, $priority = 10, $acceptedArgs = 1) { + add_filter($this->getFilterName($tag), $callback, $priority, $acceptedArgs); + } + + /** + * Get the full name of an update checker filter or action. + * + * This method adds the "puc_"/"tuc_" prefix and the "-$slug" suffix to the filter name. + * For example, "pre_inject_update" becomes "puc_pre_inject_update-plugin-slug". + * + * @param string $baseTag + * @return string + */ + public function getFilterName($baseTag) { + return $this->filterPrefix . $baseTag . '-' . $this->slug; + } + /** * Trigger a PHP error, but only when $debugMode is enabled. * From f5ae142e638f3d4b285692c806e531d1cfc3e3d2 Mon Sep 17 00:00:00 2001 From: Yahnis Elsts Date: Wed, 14 Dec 2016 21:13:51 +0200 Subject: [PATCH 04/44] Translation, localization. Fix a bunch of crashes. --- Puc/v4/Plugin/UpdateChecker.php | 137 +++-------------------- Puc/v4/Theme/Update.php | 29 +++-- Puc/v4/Theme/UpdateChecker.php | 23 +++- Puc/v4/UpdateChecker.php | 186 +++++++++++++++++++++++++++++++- 4 files changed, 236 insertions(+), 139 deletions(-) diff --git a/Puc/v4/Plugin/UpdateChecker.php b/Puc/v4/Plugin/UpdateChecker.php index cec550f..f19f36c 100644 --- a/Puc/v4/Plugin/UpdateChecker.php +++ b/Puc/v4/Plugin/UpdateChecker.php @@ -10,6 +10,8 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): */ class Puc_v4_Plugin_UpdateChecker extends Puc_v4_UpdateChecker { protected $updateClass = 'Puc_v4_Plugin_Update'; + protected $updateTransient = 'update_plugins'; + protected $translationType = 'plugin'; 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. @@ -92,7 +94,6 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): //Insert our update info into the update array maintained by WP. add_filter('site_transient_update_plugins', array($this,'injectUpdate')); //WP 3.0+ add_filter('transient_update_plugins', array($this,'injectUpdate')); //WP 2.8+ - add_filter('site_transient_update_plugins', array($this, 'injectTranslationUpdates')); add_filter('plugin_row_meta', array($this, 'addCheckForUpdatesLink'), 10, 2); add_action('admin_init', array($this, 'handleManualCheck')); @@ -101,10 +102,6 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): //Clear the version number cache when something - anything - is upgraded or WP clears the update cache. add_filter('upgrader_post_install', array($this, 'clearCachedVersion')); add_action('delete_site_transient_update_plugins', array($this, 'clearCachedVersion')); - //Clear translation updates when WP clears the update cache. - //This needs to be done directly because the library doesn't actually remove obsolete plugin updates, - //it just hides them (see getUpdate()). We can't do that with translations - too much disk I/O. - add_action('delete_site_transient_update_plugins', array($this, 'clearCachedTranslationUpdates')); if ( did_action('plugins_loaded') ) { $this->initDebugBarPanel(); @@ -228,41 +225,6 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): return $update; } - /** - * Filter a list of translation updates and return a new list that contains only updates - * that apply to the current site. - * - * @param array $translations - * @return array - */ - private function filterApplicableTranslations($translations) { - $languages = array_flip(array_values(get_available_languages())); - $installedTranslations = wp_get_installed_translations('plugins'); - if ( isset($installedTranslations[$this->slug]) ) { - $installedTranslations = $installedTranslations[$this->slug]; - } else { - $installedTranslations = array(); - } - - $applicableTranslations = array(); - foreach($translations as $translation) { - //Does it match one of the available core languages? - $isApplicable = array_key_exists($translation->language, $languages); - //Is it more recent than an already-installed translation? - if ( isset($installedTranslations[$translation->language]) ) { - $updateTimestamp = strtotime($translation->updated); - $installedTimestamp = strtotime($installedTranslations[$translation->language]['PO-Revision-Date']); - $isApplicable = $updateTimestamp > $installedTimestamp; - } - - if ( $isApplicable ) { - $applicableTranslations[] = $translation; - } - } - - return $applicableTranslations; - } - /** * Get the currently installed version of the plugin. * @@ -351,18 +313,18 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): * @return StdClass Modified update list. */ public function injectUpdate($updates){ + //TODO: Unify this. + //Is there an update to insert? $update = $this->getUpdate(); - //No update notifications for mu-plugins unless explicitly enabled. The MU plugin file - //is usually different from the main plugin file so the update wouldn't show up properly anyway. - if ( $this->isUnknownMuPlugin() ) { + if ( !$this->shouldShowUpdates() ) { $update = null; } if ( !empty($update) ) { //Let plugins filter the update info before it's passed on to WordPress. - $update = apply_filters('puc_pre_inject_update-' . $this->slug, $update); + $update = apply_filters($this->getFilterName('pre_inject_update'), $update); $updates = $this->addUpdateToList($updates, $update); } else { //Clean up any stale update info. @@ -372,6 +334,12 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): return $updates; } + protected function shouldShowUpdates() { + //No update notifications for mu-plugins unless explicitly enabled. The MU plugin file + //is usually different from the main plugin file so the update wouldn't show up properly anyway. + return !$this->isUnknownMuPlugin(); + } + /** * @param StdClass|null $updates * @param Puc_v4_Plugin_Update $updateToAdd @@ -409,58 +377,6 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): return $updates; } - /** - * Insert translation updates into the list maintained by WordPress. - * - * @param stdClass $updates - * @return stdClass - */ - public function injectTranslationUpdates($updates) { - $translationUpdates = $this->getTranslationUpdates(); - if ( empty($translationUpdates) ) { - return $updates; - } - - //Being defensive. - if ( !is_object($updates) ) { - $updates = new stdClass(); - } - if ( !isset($updates->translations) ) { - $updates->translations = array(); - } - - //In case there's a name collision with a plugin hosted on wordpress.org, - //remove any preexisting updates that match our plugin. - $translationType = 'plugin'; - $filteredTranslations = array(); - foreach($updates->translations as $translation) { - if ( ($translation['type'] === $translationType) && ($translation['slug'] === $this->slug) ) { - continue; - } - $filteredTranslations[] = $translation; - } - $updates->translations = $filteredTranslations; - - //Add our updates to the list. - foreach($translationUpdates as $update) { - $convertedUpdate = array_merge( - array( - 'type' => $translationType, - '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. - 'version' => isset($update->version) ? $update->version : ('1.' . strtotime($update->updated)), - ), - (array)$update - ); - - $updates->translations[] = $convertedUpdate; - } - - return $updates; - } - /** * Rename the update directory to match the existing plugin directory. * @@ -583,35 +499,6 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): return $update; } - /** - * Get a list of available translation updates. - * - * This method will return an empty array if there are no updates. - * Uses cached update data. - * - * @return array - */ - public function getTranslationUpdates() { - $state = $this->getUpdateState(); - if ( isset($state, $state->update, $state->update->translations) ) { - return $state->update->translations; - } - return array(); - } - - /** - * Remove all cached translation updates. - * - * @see wp_clean_update_cache - */ - public function clearCachedTranslationUpdates() { - $state = $this->getUpdateState(); - if ( isset($state, $state->update, $state->update->translations) ) { - $state->update->translations = array(); - $this->setUpdateState($state); - } - } - /** * Add a "Check for updates" link to the plugin row in the "Plugins" page. By default, * the new link will appear after the "Visit plugin site" link. diff --git a/Puc/v4/Theme/Update.php b/Puc/v4/Theme/Update.php index e0617e1..7543d7a 100644 --- a/Puc/v4/Theme/Update.php +++ b/Puc/v4/Theme/Update.php @@ -1,6 +1,6 @@ $this->slug, + 'new_version' => $this->version, + 'url' => $this->details_url, + ); - $update->theme = $this->slug; - $update->new_version = $this->version; - $update->package = $this->download_url; - $update->details_url = $this->details_url; + if ( !empty($this->download_url) ) { + $update['package'] = $this->download_url; + } return $update; } @@ -37,6 +40,18 @@ if ( class_exists('Puc_v4_Theme_Update', false) ): return $instance; } + /** + * Create a new instance by copying the necessary fields from another object. + * + * @param StdClass|Puc_v4_Theme_Update $object The source object. + * @return Puc_v4_Theme_Update The new copy. + */ + public static function fromObject($object) { + $update = new self(); + $update->copyFields($object, $update); + return $update; + } + /** * Basic validation. * diff --git a/Puc/v4/Theme/UpdateChecker.php b/Puc/v4/Theme/UpdateChecker.php index 4e1e1f7..675cca0 100644 --- a/Puc/v4/Theme/UpdateChecker.php +++ b/Puc/v4/Theme/UpdateChecker.php @@ -1,10 +1,12 @@ stylesheet = $stylesheet; $this->theme = wp_get_theme($this->stylesheet); - parent::__construct($metadataUrl, $customSlug ? $customSlug : $stylesheet, $checkPeriod, $optionName); + parent::__construct( + $metadataUrl, + $stylesheet, + $customSlug ? $customSlug : $stylesheet, + $checkPeriod, + $optionName + ); + + add_action('admin_notices', function() { + //var_dump(get_site_transient('update_plugins')); + //var_dump(get_site_transient('update_themes')); + }); } protected function installHooks() { parent::installHooks(); //Insert our update info into the update list maintained by WP. - add_filter('site_transient_update_themes', array($this,'injectUpdate')); + add_filter('site_transient_update_themes', array($this, 'injectUpdate')); //TODO: Rename the update directory to be the same as the existing directory. //add_filter('upgrader_source_selection', array($this, 'fixDirectoryName'), 10, 3); @@ -46,6 +59,10 @@ if ( class_exists('Puc_v4_Theme_UpdateChecker', false) ): //Is there an update to insert? $update = $this->getUpdate(); + if ( !$this->shouldShowUpdates() ) { + $update = null; + } + if ( !empty($update) ) { //Let themes filter the update info before it's passed on to WordPress. $update = apply_filters($this->getFilterName('pre_inject_update'), $update); diff --git a/Puc/v4/UpdateChecker.php b/Puc/v4/UpdateChecker.php index 322aaca..04d4e6e 100644 --- a/Puc/v4/UpdateChecker.php +++ b/Puc/v4/UpdateChecker.php @@ -5,6 +5,8 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): abstract class Puc_v4_UpdateChecker { protected $filterPrefix = 'puc_'; protected $updateClass = ''; + protected $updateTransient = ''; + protected $translationType = ''; //"plugin" or "theme". /** * Set to TRUE to enable error reporting. Errors are raised using trigger_error() @@ -24,7 +26,13 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): public $metadataUrl = ''; /** - * @var string Plugin slug or theme directory name. + * @var string Plugin or theme directory name. + */ + public $directoryName = ''; + + /** + * @var string The slug that will be used in update checker hooks and remote API requests. + * Usually matches the directory name unless the plugin/theme directory has been renamed. */ public $slug = ''; @@ -38,10 +46,11 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): */ protected $metadataHost = ''; - public function __construct($metadataUrl, $slug, $checkPeriod = 12, $optionName = '') { + public function __construct($metadataUrl, $directoryName, $slug = null, $checkPeriod = 12, $optionName = '') { $this->debugMode = (bool)(constant('WP_DEBUG')); $this->metadataUrl = $metadataUrl; - $this->slug = $slug; + $this->directoryName = $directoryName; + $this->slug = !empty($slug) ? $slug : $this->directoryName; $this->optionName = $optionName; if ( empty($this->optionName) ) { @@ -75,9 +84,21 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): } protected function installHooks() { - //TODO: Translation updates //TODO: Fix directory name + if ( !empty($this->updateTransient) ) { + //Insert translation updates into the update list. + add_filter('site_transient_' . $this->updateTransient, array($this, 'injectTranslationUpdates')); + + //Clear translation updates when WP clears the update cache. + //This needs to be done directly because the library doesn't actually remove obsolete plugin updates, + //it just hides them (see getUpdate()). We can't do that with translations - too much disk I/O. + add_action( + 'delete_site_transient_' . $this->updateTransient, + array($this, 'clearCachedTranslationUpdates') + ); + } + //Allow HTTP requests to the metadata URL even if it's on a local host. $this->metadataHost = @parse_url($this->metadataUrl, PHP_URL_HOST); add_filter('http_request_host_is_external', array($this, 'allowMetadataHost'), 10, 2); @@ -145,6 +166,9 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): $this->setUpdateState($state); //Save before checking in case something goes wrong $state->update = $this->requestUpdate(); + if ( isset($state->update, $state->update->translations) ) { + $state->update->translations = $this->filterApplicableTranslations($state->update->translations); + } $this->setUpdateState($state); return $this->getUpdate(); @@ -304,6 +328,160 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): trigger_error($message, $errorType); } } + + /* ------------------------------------------------------------------- + * Inject updates + * ------------------------------------------------------------------- + */ + + /** + * Should we show available updates? + * + * Usually the answer is "yes", but there are exceptions. For example, WordPress doesn't + * support automatic updates installation for mu-plugins, so PUC usually won't show update + * notifications in that case. See the plugin-specific subclass for details. + * + * Note: This method only applies to updates that are displayed (or not) in the WordPress + * admin. It doesn't affect APIs like requestUpdate and getUpdate. + * + * @return bool + */ + protected function shouldShowUpdates() { + return true; + } + + /* ------------------------------------------------------------------- + * Language packs / Translation updates + * ------------------------------------------------------------------- + */ + + /** + * Filter a list of translation updates and return a new list that contains only updates + * that apply to the current site. + * + * @param array $translations + * @return array + */ + protected function filterApplicableTranslations($translations) { + $languages = array_flip(array_values(get_available_languages())); + $installedTranslations = $this->getInstalledTranslations(); + + $applicableTranslations = array(); + foreach($translations as $translation) { + //Does it match one of the available core languages? + $isApplicable = array_key_exists($translation->language, $languages); + //Is it more recent than an already-installed translation? + if ( isset($installedTranslations[$translation->language]) ) { + $updateTimestamp = strtotime($translation->updated); + $installedTimestamp = strtotime($installedTranslations[$translation->language]['PO-Revision-Date']); + $isApplicable = $updateTimestamp > $installedTimestamp; + } + + if ( $isApplicable ) { + $applicableTranslations[] = $translation; + } + } + + return $applicableTranslations; + } + + /** + * Get a list of installed translations for this plugin or theme. + * + * @return array + */ + protected function getInstalledTranslations() { + $installedTranslations = wp_get_installed_translations($this->translationType . 's'); + if ( isset($installedTranslations[$this->directoryName]) ) { + $installedTranslations = $installedTranslations[$this->directoryName]; + } else { + $installedTranslations = array(); + } + return $installedTranslations; + } + + /** + * Insert translation updates into the list maintained by WordPress. + * + * @param stdClass $updates + * @return stdClass + */ + public function injectTranslationUpdates($updates) { + $translationUpdates = $this->getTranslationUpdates(); + if ( empty($translationUpdates) ) { + return $updates; + } + + //Being defensive. + if ( !is_object($updates) ) { + $updates = new stdClass(); + } + if ( !isset($updates->translations) ) { + $updates->translations = array(); + } + + //In case there's a name collision with a plugin or theme hosted on wordpress.org, + //remove any preexisting updates that match our thing. + $filteredTranslations = array(); + foreach($updates->translations as $translation) { + if ( + ($translation['type'] === $this->translationType) + && ($translation['slug'] === $this->directoryName) + ) { + continue; + } + $filteredTranslations[] = $translation; + } + $updates->translations = $filteredTranslations; + + //Add our updates to the list. + foreach($translationUpdates as $update) { + $convertedUpdate = array_merge( + array( + 'type' => $this->translationType, + 'slug' => $this->directoryName, + 'autoupdate' => 0, + //AFAICT, WordPress doesn't actually use the "version" field for anything. + //But lets make sure it's there, just in case. + 'version' => isset($update->version) ? $update->version : ('1.' . strtotime($update->updated)), + ), + (array)$update + ); + + $updates->translations[] = $convertedUpdate; + } + + return $updates; + } + + /** + * Get a list of available translation updates. + * + * This method will return an empty array if there are no updates. + * Uses cached update data. + * + * @return array + */ + public function getTranslationUpdates() { + $state = $this->getUpdateState(); + if ( isset($state, $state->update, $state->update->translations) ) { + return $state->update->translations; + } + return array(); + } + + /** + * Remove all cached translation updates. + * + * @see wp_clean_update_cache + */ + public function clearCachedTranslationUpdates() { + $state = $this->getUpdateState(); + if ( isset($state, $state->update, $state->update->translations) ) { + $state->update->translations = array(); + $this->setUpdateState($state); + } + } } endif; \ No newline at end of file From 2a176fc665e73e3077d0b565fd5f60d3fdef8ee1 Mon Sep 17 00:00:00 2001 From: Yahnis Elsts Date: Fri, 16 Dec 2016 17:53:15 +0200 Subject: [PATCH 05/44] Move update injection and directory name fixes to the base UpdateChecker class. --- Puc/v4/Plugin/UpdateChecker.php | 222 +++++--------------------------- Puc/v4/Theme/Update.php | 1 + Puc/v4/Theme/UpdateChecker.php | 41 ++---- Puc/v4/UpdateChecker.php | 212 ++++++++++++++++++++++++++++-- Puc/v4/UpgraderStatus.php | 104 +++++++++++---- 5 files changed, 327 insertions(+), 253 deletions(-) diff --git a/Puc/v4/Plugin/UpdateChecker.php b/Puc/v4/Plugin/UpdateChecker.php index f19f36c..18c2594 100644 --- a/Puc/v4/Plugin/UpdateChecker.php +++ b/Puc/v4/Plugin/UpdateChecker.php @@ -17,8 +17,6 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): public $pluginFile = ''; //Plugin filename relative to the plugins directory. Many WP APIs use this to identify plugins. public $muPluginFile = ''; //For MU plugins, the plugin filename relative to the mu-plugins directory. - protected $upgraderStatus; - private $debugBarPlugin = null; private $cachedInstalledVersion = null; @@ -61,17 +59,12 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): $this->muPluginFile = $this->pluginFile; } - $this->upgraderStatus = new Puc_v4_UpgraderStatus(); - - parent::__construct($metadataUrl, $slug, $checkPeriod, $optionName); + parent::__construct($metadataUrl, dirname($this->pluginFile), $slug, $checkPeriod, $optionName); } /** * Create an instance of the scheduler. * - * This is implemented as a method to make it possible for plugins to subclass the update checker - * and substitute their own scheduler. - * * @param int $checkPeriod * @return Puc_v4_Scheduler */ @@ -91,10 +84,6 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): //Override requests for plugin information add_filter('plugins_api', array($this, 'injectInfo'), 20, 3); - //Insert our update info into the update array maintained by WP. - add_filter('site_transient_update_plugins', array($this,'injectUpdate')); //WP 3.0+ - add_filter('transient_update_plugins', array($this,'injectUpdate')); //WP 2.8+ - add_filter('plugin_row_meta', array($this, 'addCheckForUpdatesLink'), 10, 2); add_action('admin_init', array($this, 'handleManualCheck')); add_action('all_admin_notices', array($this, 'displayManualCheckResult')); @@ -109,13 +98,6 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): add_action('plugins_loaded', array($this, 'initDebugBarPanel')); } - //Rename the update directory to be the same as the existing directory. - add_filter('upgrader_source_selection', array($this, 'fixDirectoryName'), 10, 3); - - //Enable language support (i18n). - //TODO: The directory path has changed. - load_plugin_textdomain('plugin-update-checker', false, plugin_basename(dirname(__FILE__)) . '/languages'); - parent::installHooks(); } @@ -174,35 +156,6 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): return $pluginInfo; } - /** - * 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('puc_no_response_code', 'wp_remote_get() returned an unexpected result.'); - } - - if ( $result['response']['code'] !== 200 ) { - return new WP_Error( - 'puc_unexpected_response_code', - 'HTTP response code is ' . $result['response']['code'] . ' (expected: 200)' - ); - } - - if ( empty($result['body']) ) { - return new WP_Error('puc_empty_response', 'The metadata file appears to be empty.'); - } - - return true; - } - /** * Retrieve the latest update (if any) from the configured API endpoint. * @@ -298,7 +251,7 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): } $pluginInfo = $this->requestInfo(); - $pluginInfo = apply_filters('puc_pre_inject_info-' . $this->slug, $pluginInfo); + $pluginInfo = apply_filters($this->getFilterName('pre_inject_info'), $pluginInfo); if ( $pluginInfo ) { return $pluginInfo->toWpFormat(); } @@ -306,34 +259,6 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): return $result; } - /** - * Insert the latest update (if any) into the update list maintained by WP. - * - * @param StdClass $updates Update list. - * @return StdClass Modified update list. - */ - public function injectUpdate($updates){ - //TODO: Unify this. - - //Is there an update to insert? - $update = $this->getUpdate(); - - if ( !$this->shouldShowUpdates() ) { - $update = null; - } - - if ( !empty($update) ) { - //Let plugins filter the update info before it's passed on to WordPress. - $update = apply_filters($this->getFilterName('pre_inject_update'), $update); - $updates = $this->addUpdateToList($updates, $update); - } else { - //Clean up any stale update info. - $updates = $this->removeUpdateFromList($updates); - } - - return $updates; - } - protected function shouldShowUpdates() { //No update notifications for mu-plugins unless explicitly enabled. The MU plugin file //is usually different from the main plugin file so the update wouldn't show up properly anyway. @@ -341,142 +266,62 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): } /** - * @param StdClass|null $updates - * @param Puc_v4_Plugin_Update $updateToAdd - * @return StdClass + * @param stdClass|null $updates + * @param stdClass $updateToAdd + * @return stdClass */ - private function addUpdateToList($updates, $updateToAdd) { - if ( !is_object($updates) ) { - $updates = new stdClass(); - $updates->response = array(); - } - - $wpUpdate = $updateToAdd->toWpFormat(); - $pluginFile = $this->pluginFile; - + protected function addUpdateToList($updates, $updateToAdd) { if ( $this->isMuPlugin() ) { - //WP does not support automatic update installation for mu-plugins, but we can still display a notice. - $wpUpdate->package = null; - $pluginFile = $this->muPluginFile; + //WP does not support automatic update installation for mu-plugins, but we can + //still display a notice. + $updateToAdd->package = null; } - $updates->response[$pluginFile] = $wpUpdate; - return $updates; + return parent::addUpdateToList($updates, $updateToAdd); } /** * @param stdClass|null $updates * @return stdClass|null */ - private function removeUpdateFromList($updates) { - if ( isset($updates, $updates->response) ) { - unset($updates->response[$this->pluginFile]); - if ( !empty($this->muPluginFile) ) { - unset($updates->response[$this->muPluginFile]); - } + protected function removeUpdateFromList($updates) { + $updates = parent::removeUpdateFromList($updates); + if ( !empty($this->muPluginFile) && isset($updates, $updates->response) ) { + unset($updates->response[$this->muPluginFile]); } return $updates; } /** - * Rename the update directory to match the existing plugin directory. + * For plugins, the update array is indexed by the plugin filename relative to the "plugins" + * directory. Example: "plugin-name/plugin.php". * - * When WordPress installs a plugin or theme update, it assumes that the ZIP file will contain - * exactly one directory, and that the directory name will be the same as the directory where - * the plugin/theme is currently installed. - * - * GitHub and other repositories provide ZIP downloads, but they often use directory names like - * "project-branch" or "project-tag-hash". We need to change the name to the actual plugin folder. - * - * This is a hook callback. Don't call it from a plugin. - * - * @param string $source The directory to copy to /wp-content/plugins. Usually a subdirectory of $remoteSource. - * @param string $remoteSource WordPress has extracted the update to this directory. - * @param WP_Upgrader $upgrader - * @return string|WP_Error + * @return string */ - public function fixDirectoryName($source, $remoteSource, $upgrader) { - global $wp_filesystem; /** @var WP_Filesystem_Base $wp_filesystem */ - - //Basic sanity checks. - if ( !isset($source, $remoteSource, $upgrader, $upgrader->skin, $wp_filesystem) ) { - return $source; + protected function getUpdateListKey() { + if ( $this->isMuPlugin() ) { + return $this->muPluginFile; } - - //If WordPress is upgrading anything other than our plugin, leave the directory name unchanged. - if ( !$this->isPluginBeingUpgraded($upgrader) ) { - return $source; - } - - //Rename the source to match the existing plugin directory. - $pluginDirectoryName = dirname($this->pluginFile); - if ( $pluginDirectoryName === '.' ) { - return $source; - } - $correctedSource = trailingslashit($remoteSource) . $pluginDirectoryName . '/'; - if ( $source !== $correctedSource ) { - //The update archive should contain a single directory that contains the rest of plugin files. Otherwise, - //WordPress will try to copy the entire working directory ($source == $remoteSource). We can't rename - //$remoteSource because that would break WordPress code that cleans up temporary files after update. - if ( $this->isBadDirectoryStructure($remoteSource) ) { - return new WP_Error( - 'puc-incorrect-directory-structure', - sprintf( - 'The directory structure of the update is incorrect. All plugin files should be inside ' . - 'a directory named %s, not at the root of the ZIP file.', - htmlentities($this->slug) - ) - ); - } - - /** @var WP_Upgrader_Skin $upgrader->skin */ - $upgrader->skin->feedback(sprintf( - 'Renaming %s to %s…', - '' . basename($source) . '', - '' . $pluginDirectoryName . '' - )); - - if ( $wp_filesystem->move($source, $correctedSource, true) ) { - $upgrader->skin->feedback('Plugin directory successfully renamed.'); - return $correctedSource; - } else { - return new WP_Error( - 'puc-rename-failed', - 'Unable to rename the update to match the existing plugin directory.' - ); - } - } - - return $source; + return $this->pluginFile; } /** - * Check for incorrect update directory structure. An update must contain a single directory, - * all other files should be inside that directory. - * - * @param string $remoteSource Directory path. - * @return bool - */ - private function isBadDirectoryStructure($remoteSource) { - global $wp_filesystem; /** @var WP_Filesystem_Base $wp_filesystem */ - - $sourceFiles = $wp_filesystem->dirlist($remoteSource); - if ( is_array($sourceFiles) ) { - $sourceFiles = array_keys($sourceFiles); - $firstFilePath = trailingslashit($remoteSource) . $sourceFiles[0]; - return (count($sourceFiles) > 1) || (!$wp_filesystem->is_dir($firstFilePath)); - } - - //Assume it's fine. - return false; - } - - /** - * Is there and update being installed RIGHT NOW, for this specific plugin? + * Alias for isBeingUpgraded(). * + * @deprecated * @param WP_Upgrader|null $upgrader The upgrader that's performing the current update. * @return bool */ public function isPluginBeingUpgraded($upgrader = null) { + return $this->isBeingUpgraded($upgrader); + } + + /** + * Is there an update being installed for this plugin, right now? + * + * @param WP_Upgrader|null $upgrader + * @return bool + */ + public function isBeingUpgraded($upgrader = null) { return $this->upgraderStatus->isPluginBeingUpgraded($this->pluginFile, $upgrader); } @@ -494,6 +339,7 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): public function getUpdate() { $update = parent::getUpdate(); if ( isset($update) ) { + /** @var Puc_v4_Plugin_Update $update */ $update->filename = $this->pluginFile; } return $update; diff --git a/Puc/v4/Theme/Update.php b/Puc/v4/Theme/Update.php index 7543d7a..221f947 100644 --- a/Puc/v4/Theme/Update.php +++ b/Puc/v4/Theme/Update.php @@ -9,6 +9,7 @@ if ( !class_exists('Puc_v4_Theme_Update', false) ): /** * Transform the metadata into the format used by WordPress core. + * Note the inconsistency: WP stores plugin updates as objects and theme updates as arrays. * * @return array */ diff --git a/Puc/v4/Theme/UpdateChecker.php b/Puc/v4/Theme/UpdateChecker.php index 675cca0..5becd39 100644 --- a/Puc/v4/Theme/UpdateChecker.php +++ b/Puc/v4/Theme/UpdateChecker.php @@ -41,38 +41,15 @@ if ( !class_exists('Puc_v4_Theme_UpdateChecker', false) ): protected function installHooks() { parent::installHooks(); - - //Insert our update info into the update list maintained by WP. - add_filter('site_transient_update_themes', array($this, 'injectUpdate')); - - //TODO: Rename the update directory to be the same as the existing directory. - //add_filter('upgrader_source_selection', array($this, 'fixDirectoryName'), 10, 3); } /** - * Insert the latest update (if any) into the update list maintained by WP. + * For themes, the update array is indexed by theme directory name. * - * @param StdClass $updates Update list. - * @return StdClass Modified update list. + * @return string */ - public function injectUpdate($updates) { - //Is there an update to insert? - $update = $this->getUpdate(); - - if ( !$this->shouldShowUpdates() ) { - $update = null; - } - - if ( !empty($update) ) { - //Let themes filter the update info before it's passed on to WordPress. - $update = apply_filters($this->getFilterName('pre_inject_update'), $update); - $updates->response[$this->stylesheet] = $update->toWpFormat(); - } else { - //Clean up any stale update info. - unset($updates->response[$this->stylesheet]); - } - - return $updates; + protected function getUpdateListKey() { + return $this->directoryName; } /** @@ -148,6 +125,16 @@ if ( !class_exists('Puc_v4_Theme_UpdateChecker', false) ): } //TODO: Various add*filter utilities for backwards compatibility. + + /** + * Is there an update being installed right now for this theme? + * + * @param WP_Upgrader|null $upgrader The upgrader that's performing the current update. + * @return bool + */ + public function isBeingUpgraded($upgrader = null) { + return $this->upgraderStatus->isThemeBeingUpgraded($this->stylesheet, $upgrader); + } } endif; \ No newline at end of file diff --git a/Puc/v4/UpdateChecker.php b/Puc/v4/UpdateChecker.php index 04d4e6e..069d2ee 100644 --- a/Puc/v4/UpdateChecker.php +++ b/Puc/v4/UpdateChecker.php @@ -46,6 +46,11 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): */ protected $metadataHost = ''; + /** + * @var Puc_v4_UpgraderStatus + */ + protected $upgraderStatus; + public function __construct($metadataUrl, $directoryName, $slug = null, $checkPeriod = 12, $optionName = '') { $this->debugMode = (bool)(constant('WP_DEBUG')); $this->metadataUrl = $metadataUrl; @@ -64,6 +69,7 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): } $this->scheduler = $this->createScheduler($checkPeriod); + $this->upgraderStatus = new Puc_v4_UpgraderStatus(); $this->loadTextDomain(); $this->installHooks(); @@ -84,24 +90,29 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): } protected function installHooks() { - //TODO: Fix directory name + //Insert our update info into the update array maintained by WP. + add_filter('site_transient_' . $this->updateTransient, array($this,'injectUpdate')); - if ( !empty($this->updateTransient) ) { - //Insert translation updates into the update list. - add_filter('site_transient_' . $this->updateTransient, array($this, 'injectTranslationUpdates')); + //Insert translation updates into the update list. + add_filter('site_transient_' . $this->updateTransient, array($this, 'injectTranslationUpdates')); - //Clear translation updates when WP clears the update cache. - //This needs to be done directly because the library doesn't actually remove obsolete plugin updates, - //it just hides them (see getUpdate()). We can't do that with translations - too much disk I/O. - add_action( - 'delete_site_transient_' . $this->updateTransient, - array($this, 'clearCachedTranslationUpdates') - ); - } + //Clear translation updates when WP clears the update cache. + //This needs to be done directly because the library doesn't actually remove obsolete plugin updates, + //it just hides them (see getUpdate()). We can't do that with translations - too much disk I/O. + add_action( + 'delete_site_transient_' . $this->updateTransient, + array($this, 'clearCachedTranslationUpdates') + ); + + //Rename the update directory to be the same as the existing directory. + add_filter('upgrader_source_selection', array($this, 'fixDirectoryName'), 10, 3); //Allow HTTP requests to the metadata URL even if it's on a local host. $this->metadataHost = @parse_url($this->metadataUrl, PHP_URL_HOST); add_filter('http_request_host_is_external', array($this, 'allowMetadataHost'), 10, 2); + + //TODO: Debugbar + //TODO: Utility functions for adding filters. } /** @@ -132,6 +143,9 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): /** * Create an instance of the scheduler. * + * This is implemented as a method to make it possible for plugins to subclass the update checker + * and substitute their own scheduler. + * * @param int $checkPeriod * @return Puc_v4_Scheduler */ @@ -167,6 +181,7 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): $state->update = $this->requestUpdate(); if ( isset($state->update, $state->update->translations) ) { + //TODO: Should this be called in requestUpdate, like PluginUpdater does? $state->update->translations = $this->filterApplicableTranslations($state->update->translations); } $this->setUpdateState($state); @@ -225,7 +240,7 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): * Uses cached update data. To retrieve update information straight from * the metadata URL, call requestUpdate() instead. * - * @return Puc_v4_Update|Puc_v4_Plugin_Update|Puc_v4_Theme_Update|null + * @return Puc_v4_Update|null */ public function getUpdate() { $state = $this->getUpdateState(); /** @var StdClass $state */ @@ -334,6 +349,67 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): * ------------------------------------------------------------------- */ + /** + * Insert the latest update (if any) into the update list maintained by WP. + * + * @param stdClass $updates Update list. + * @return stdClass Modified update list. + */ + public function injectUpdate($updates) { + //Is there an update to insert? + $update = $this->getUpdate(); + + if ( !$this->shouldShowUpdates() ) { + $update = null; + } + + if ( !empty($update) ) { + //Let plugins filter the update info before it's passed on to WordPress. + $update = apply_filters($this->getFilterName('pre_inject_update'), $update); + $updates = $this->addUpdateToList($updates, $update->toWpFormat()); + } else { + //Clean up any stale update info. + $updates = $this->removeUpdateFromList($updates); + } + + return $updates; + } + + /** + * @param stdClass|null $updates + * @param stdClass|array $updateToAdd + * @return stdClass + */ + protected function addUpdateToList($updates, $updateToAdd) { + if ( !is_object($updates) ) { + $updates = new stdClass(); + $updates->response = array(); + } + + $updates->response[$this->getUpdateListKey()] = $updateToAdd; + return $updates; + } + + /** + * @param stdClass|null $updates + * @return stdClass|null + */ + protected function removeUpdateFromList($updates) { + if ( isset($updates, $updates->response) ) { + unset($updates->response[$this->getUpdateListKey()]); + } + return $updates; + } + + /** + * Get the key that will be used when adding updates to the update list that's maintained + * by the WordPress core. The list is always an associative array, but the key is different + * for plugins and themes. + * + * @return string + */ + abstract protected function getUpdateListKey(); + /** * Should we show available updates? * @@ -482,6 +558,116 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): $this->setUpdateState($state); } } + + /* ------------------------------------------------------------------- + * Fix directory name when installing updates + * ------------------------------------------------------------------- + */ + + /** + * Rename the update directory to match the existing plugin/theme directory. + * + * When WordPress installs a plugin or theme update, it assumes that the ZIP file will contain + * exactly one directory, and that the directory name will be the same as the directory where + * the plugin or theme is currently installed. + * + * GitHub and other repositories provide ZIP downloads, but they often use directory names like + * "project-branch" or "project-tag-hash". We need to change the name to the actual plugin folder. + * + * This is a hook callback. Don't call it from a plugin. + * + * @access protected + * + * @param string $source The directory to copy to /wp-content/plugins or /wp-content/themes. Usually a subdirectory of $remoteSource. + * @param string $remoteSource WordPress has extracted the update to this directory. + * @param WP_Upgrader $upgrader + * @return string|WP_Error + */ + public function fixDirectoryName($source, $remoteSource, $upgrader) { + global $wp_filesystem; + /** @var WP_Filesystem_Base $wp_filesystem */ + + //Basic sanity checks. + if ( !isset($source, $remoteSource, $upgrader, $upgrader->skin, $wp_filesystem) ) { + return $source; + } + + //If WordPress is upgrading anything other than our plugin/theme, leave the directory name unchanged. + if ( !$this->isBeingUpgraded($upgrader) ) { + return $source; + } + + //Rename the source to match the existing directory. + if ( $this->directoryName === '.' ) { + return $source; + } + $correctedSource = trailingslashit($remoteSource) . $this->directoryName . '/'; + if ( $source !== $correctedSource ) { + //The update archive should contain a single directory that contains the rest of plugin/theme files. + //Otherwise, WordPress will try to copy the entire working directory ($source == $remoteSource). + //We can't rename $remoteSource because that would break WordPress code that cleans up temporary files + //after update. + if ($this->isBadDirectoryStructure($remoteSource)) { + return new WP_Error( + 'puc-incorrect-directory-structure', + sprintf( + 'The directory structure of the update is incorrect. All files should be inside ' . + 'a directory named %s, not at the root of the ZIP archive.', + htmlentities($this->slug) + ) + ); + } + + /** @var WP_Upgrader_Skin $upgrader ->skin */ + $upgrader->skin->feedback(sprintf( + 'Renaming %s to %s…', + '' . basename($source) . '', + '' . $this->directoryName . '' + )); + + if ($wp_filesystem->move($source, $correctedSource, true)) { + $upgrader->skin->feedback('Directory successfully renamed.'); + return $correctedSource; + } else { + return new WP_Error( + 'puc-rename-failed', + 'Unable to rename the update to match the existing directory.' + ); + } + } + + return $source; + } + + /** + * Is there an update being installed right now, for this plugin or theme? + * + * @param WP_Upgrader|null $upgrader The upgrader that's performing the current update. + * @return bool + */ + abstract public function isBeingUpgraded($upgrader = null); + + /** + * Check for incorrect update directory structure. An update must contain a single directory, + * all other files should be inside that directory. + * + * @param string $remoteSource Directory path. + * @return bool + */ + protected function isBadDirectoryStructure($remoteSource) { + global $wp_filesystem; + /** @var WP_Filesystem_Base $wp_filesystem */ + + $sourceFiles = $wp_filesystem->dirlist($remoteSource); + if ( is_array($sourceFiles) ) { + $sourceFiles = array_keys($sourceFiles); + $firstFilePath = trailingslashit($remoteSource) . $sourceFiles[0]; + return (count($sourceFiles) > 1) || (!$wp_filesystem->is_dir($firstFilePath)); + } + + //Assume it's fine. + return false; + } } endif; \ No newline at end of file diff --git a/Puc/v4/UpgraderStatus.php b/Puc/v4/UpgraderStatus.php index 4aa8ec0..94c32a2 100644 --- a/Puc/v4/UpgraderStatus.php +++ b/Puc/v4/UpgraderStatus.php @@ -2,21 +2,22 @@ if ( !class_exists('Puc_v4_UpgraderStatus', false) ): /** - * A utility class that helps figure out which plugin WordPress is upgrading. + * A utility class that helps figure out which plugin or theme WordPress is upgrading. * - * It may seem strange to have an separate class just for that, but the task is surprisingly complicated. + * It may seem strange to have a separate class just for that, but the task is surprisingly complicated. * Core classes like Plugin_Upgrader don't expose the plugin file name during an in-progress update (AFAICT). * This class uses a few workarounds and heuristics to get the file name. */ class Puc_v4_UpgraderStatus { - private $upgradedPluginFile = null; //The plugin that is currently being upgraded by WordPress. + private $currentType = null; //"plugin" or "theme". + private $currentId = null; //Plugin basename or theme directory name. public function __construct() { - //Keep track of which plugin WordPress is currently upgrading. + //Keep track of which plugin/theme WordPress is currently upgrading. add_filter('upgrader_pre_install', array($this, 'setUpgradedPlugin'), 10, 2); add_filter('upgrader_package_options', array($this, 'setUpgradedPluginFromOptions'), 10, 1); - add_filter('upgrader_post_install', array($this, 'clearUpgradedPlugin'), 10, 1); - add_action('upgrader_process_complete', array($this, 'clearUpgradedPlugin'), 10, 1); + add_filter('upgrader_post_install', array($this, 'clearUpgradedThing'), 10, 1); + add_action('upgrader_process_complete', array($this, 'clearUpgradedThing'), 10, 1); } /** @@ -30,33 +31,76 @@ if ( !class_exists('Puc_v4_UpgraderStatus', false) ): * @return bool True if the plugin identified by $pluginFile is being upgraded. */ public function isPluginBeingUpgraded($pluginFile, $upgrader = null) { - if ( isset($upgrader) ) { - $upgradedPluginFile = $this->getPluginBeingUpgradedBy($upgrader); - if ( !empty($upgradedPluginFile) ) { - $this->upgradedPluginFile = $upgradedPluginFile; - } - } - return ( !empty($this->upgradedPluginFile) && ($this->upgradedPluginFile === $pluginFile) ); + return $this->isBeingUpgraded('plugin', $pluginFile, $upgrader); } /** - * Get the file name of the plugin that's currently being upgraded. + * Is there an update being installed for a specific theme? + * + * @param string $stylesheet Theme directory name. + * @param WP_Upgrader|null $upgrader The upgrader that's performing the current update. + * @return bool + */ + public function isThemeBeingUpgraded($stylesheet, $upgrader = null) { + return $this->isBeingUpgraded('theme', $stylesheet, $upgrader); + } + + /** + * Check if a specific theme or plugin is being upgraded. + * + * @param string $type + * @param string $id + * @param Plugin_Upgrader|WP_Upgrader|null $upgrader + * @return bool + */ + protected function isBeingUpgraded($type, $id, $upgrader = null) { + if ( isset($upgrader) ) { + list($currentType, $currentId) = $this->getThingBeingUpgradedBy($upgrader); + if ( $currentType !== null ) { + $this->currentType = $currentType; + $this->currentId = $currentId; + } + } + return ($this->currentType === $type) && ($this->currentId === $id); + } + + /** + * Figure out which theme or plugin is being upgraded by a WP_Upgrader instance. + * + * Returns an array with two items. The first item is the type of the thing that's being + * upgraded: "plugin" or "theme". The second item is either the plugin basename or + * the theme directory name. If we can't determine what the upgrader is doing, both items + * will be NULL. + * + * Examples: + * ['plugin', 'plugin-dir-name/plugin.php'] + * ['theme', 'theme-dir-name'] * * @param Plugin_Upgrader|WP_Upgrader $upgrader - * @return string|null + * @return array */ - private function getPluginBeingUpgradedBy($upgrader) { + private function getThingBeingUpgradedBy($upgrader) { if ( !isset($upgrader, $upgrader->skin) ) { - return null; + return array(null, null); } - //Figure out which plugin is being upgraded. + //Figure out which plugin or theme is being upgraded. $pluginFile = null; + $themeDirectoryName = null; + $skin = $upgrader->skin; if ( $skin instanceof Plugin_Upgrader_Skin ) { if ( isset($skin->plugin) && is_string($skin->plugin) && ($skin->plugin !== '') ) { $pluginFile = $skin->plugin; } + } elseif ( $skin instanceof Theme_Upgrader_Skin ) { + if ( isset($skin->theme) && is_string($skin->theme) && ($skin->theme !== '') ) { + $themeDirectoryName = $skin->theme; + } + } elseif ( $upgrader->skin instanceof Bulk_Theme_Upgrader_Skin ) { + if ( isset($skin->theme_info) && ($skin->theme_info instanceof WP_Theme) ) { + $themeDirectoryName = $skin->theme_info->get_stylesheet(); + } } elseif ( isset($skin->plugin_info) && is_array($skin->plugin_info) ) { //This case is tricky because Bulk_Plugin_Upgrader_Skin (etc) doesn't actually store the plugin //filename anywhere. Instead, it has the plugin headers in $plugin_info. So the best we can @@ -64,7 +108,12 @@ if ( !class_exists('Puc_v4_UpgraderStatus', false) ): $pluginFile = $this->identifyPluginByHeaders($skin->plugin_info); } - return $pluginFile; + if ( $pluginFile !== null ) { + return array('plugin', $pluginFile); + } elseif ( $themeDirectoryName !== null ) { + return array('theme', $themeDirectoryName); + } + return array(null, null); } /** @@ -107,9 +156,11 @@ if ( !class_exists('Puc_v4_UpgraderStatus', false) ): */ public function setUpgradedPlugin($input, $hookExtra) { if (!empty($hookExtra['plugin']) && is_string($hookExtra['plugin'])) { - $this->upgradedPluginFile = $hookExtra['plugin']; + $this->currentId = $hookExtra['plugin']; + $this->currentType = 'plugin'; } else { - $this->upgradedPluginFile = null; + $this->currentType = null; + $this->currentId = null; } return $input; } @@ -122,9 +173,11 @@ if ( !class_exists('Puc_v4_UpgraderStatus', false) ): */ public function setUpgradedPluginFromOptions($options) { if (isset($options['hook_extra']['plugin']) && is_string($options['hook_extra']['plugin'])) { - $this->upgradedPluginFile = $options['hook_extra']['plugin']; + $this->currentType = 'plugin'; + $this->currentId = $options['hook_extra']['plugin']; } else { - $this->upgradedPluginFile = null; + $this->currentType = null; + $this->currentId = null; } return $options; } @@ -135,8 +188,9 @@ if ( !class_exists('Puc_v4_UpgraderStatus', false) ): * @param mixed $input * @return mixed Returns $input unaltered. */ - public function clearUpgradedPlugin($input = null) { - $this->upgradedPluginFile = null; + public function clearUpgradedThing($input = null) { + $this->currentId = null; + $this->currentType = null; return $input; } } From 3e11878ac87373bb7eafbf9f9714f3966e4be696 Mon Sep 17 00:00:00 2001 From: Yahnis Elsts Date: Sat, 17 Dec 2016 17:57:27 +0200 Subject: [PATCH 06/44] DebugBar integration for themes. Change filter structure from puc_/tuc_* to puc_tag_[theme]-$slug. Not happy with that yet. Fix a bunch of minor bugs. --- Puc/v4/DebugBar/Extension.php | 117 ++++++++++++++++++++ Puc/v4/DebugBar/Panel.php | 162 ++++++++++++++++++++++++++++ Puc/v4/DebugBar/PluginExtension.php | 33 ++++++ Puc/v4/DebugBar/PluginPanel.php | 37 +++++++ Puc/v4/DebugBar/ThemePanel.php | 20 ++++ Puc/v4/Metadata.php | 6 +- Puc/v4/Plugin/Update.php | 3 +- Puc/v4/Plugin/UpdateChecker.php | 21 +--- Puc/v4/Theme/Update.php | 4 +- Puc/v4/Theme/UpdateChecker.php | 17 ++- Puc/v4/UpdateChecker.php | 51 +++++++-- css/puc-debug-bar.css | 2 +- debug-bar-panel.php | 146 ------------------------- debug-bar-plugin.php | 102 ------------------ js/debug-bar.js | 20 ++-- 15 files changed, 445 insertions(+), 296 deletions(-) create mode 100644 Puc/v4/DebugBar/Extension.php create mode 100644 Puc/v4/DebugBar/Panel.php create mode 100644 Puc/v4/DebugBar/PluginExtension.php create mode 100644 Puc/v4/DebugBar/PluginPanel.php create mode 100644 Puc/v4/DebugBar/ThemePanel.php delete mode 100644 debug-bar-panel.php delete mode 100644 debug-bar-plugin.php diff --git a/Puc/v4/DebugBar/Extension.php b/Puc/v4/DebugBar/Extension.php new file mode 100644 index 0000000..a307952 --- /dev/null +++ b/Puc/v4/DebugBar/Extension.php @@ -0,0 +1,117 @@ +updateChecker = $updateChecker; + if ( isset($panelClass) ) { + $this->panelClass = $panelClass; + } + + add_filter('debug_bar_panels', array($this, 'addDebugBarPanel')); + add_action('debug_bar_enqueue_scripts', array($this, 'enqueuePanelDependencies')); + + add_action('wp_ajax_puc_debug_check_now', array($this, 'ajaxCheckNow')); + } + + /** + * Register the PUC Debug Bar panel. + * + * @param array $panels + * @return array + */ + public function addDebugBarPanel($panels) { + if ( $this->updateChecker->userCanInstallUpdates() ) { + $panels[] = new $this->panelClass($this->updateChecker); + } + return $panels; + } + + /** + * Enqueue our Debug Bar scripts and styles. + */ + public function enqueuePanelDependencies() { + wp_enqueue_style( + 'puc-debug-bar-style-v4', + $this->getLibraryUrl("/css/puc-debug-bar.css"), + array('debug-bar'), + '20161217' + ); + + wp_enqueue_script( + 'puc-debug-bar-js-v4', + $this->getLibraryUrl("/js/debug-bar.js"), + array('jquery'), + '20161217-3' + ); + } + + /** + * Run an update check and output the result. Useful for making sure that + * the update checking process works as expected. + */ + public function ajaxCheckNow() { + if ( $_POST['uid'] !== $this->updateChecker->getFilterName('uid') ) { + return; + } + $this->preAjaxReqest(); + $update = $this->updateChecker->checkForUpdates(); + if ( $update !== null ) { + echo "An update is available:"; + echo '
', htmlentities(print_r($update, true)), '
'; + } else { + echo 'No updates found.'; + } + exit; + } + + /** + * Check access permissions and enable error display (for debugging). + */ + protected function preAjaxReqest() { + if ( !$this->updateChecker->userCanInstallUpdates() ) { + die('Access denied'); + } + check_ajax_referer('puc-ajax'); + + error_reporting(E_ALL); + @ini_set('display_errors','On'); + } + + /** + * @param string $filePath + * @return string + */ + private function getLibraryUrl($filePath) { + $absolutePath = realpath(dirname(__FILE__) . '/../../../' . ltrim($filePath, '/')); + + //Where is the library located inside the WordPress directory structure? + $absolutePath = wp_normalize_path($absolutePath); + + $pluginDir = wp_normalize_path(WP_PLUGIN_DIR); + $muPluginDir = wp_normalize_path(WPMU_PLUGIN_DIR); + $themeDir = wp_normalize_path(get_theme_root()); + + if ( (strpos($absolutePath, $pluginDir) === 0) || (strpos($absolutePath, $muPluginDir) === 0) ) { + //It's part of a plugin. + return plugins_url(basename($absolutePath), $absolutePath); + } else if ( strpos($absolutePath, $themeDir) === 0 ) { + //It's part of a theme. + $relativePath = substr($absolutePath, strlen($themeDir) + 1); + $template = substr($relativePath, 0, strpos($relativePath, '/')); + $baseUrl = get_theme_root_uri($template); + + if ( !empty($baseUrl) && $relativePath ) { + return $baseUrl . '/' . $relativePath; + } + } + + return ''; + } + } + +endif; \ No newline at end of file diff --git a/Puc/v4/DebugBar/Panel.php b/Puc/v4/DebugBar/Panel.php new file mode 100644 index 0000000..2faa93d --- /dev/null +++ b/Puc/v4/DebugBar/Panel.php @@ -0,0 +1,162 @@ +'; + + public function __construct($updateChecker) { + $this->updateChecker = $updateChecker; + $title = sprintf( + 'PUC (%s)', + esc_attr($this->updateChecker->getFilterName('uid')), + $this->updateChecker->slug + ); + parent::__construct($title); + } + + public function render() { + printf( + '
', + esc_attr($this->updateChecker->getFilterName('debug-bar-panel')), + esc_attr($this->updateChecker->slug), + esc_attr($this->updateChecker->getFilterName('uid')), + esc_attr(wp_create_nonce('puc-ajax')) + ); + + $this->displayConfiguration(); + $this->displayStatus(); + $this->displayCurrentUpdate(); + + echo '
'; + } + + private function displayConfiguration() { + echo '

Configuration

'; + echo ''; + $this->displayConfigHeader(); + $this->row('Slug', htmlentities($this->updateChecker->slug)); + $this->row('DB option', htmlentities($this->updateChecker->optionName)); + + $requestInfoButton = $this->getMetadataButton(); + $this->row('Metadata URL', htmlentities($this->updateChecker->metadataUrl) . ' ' . $requestInfoButton . $this->responseBox); + + $scheduler = $this->updateChecker->scheduler; + if ( $scheduler->checkPeriod > 0 ) { + $this->row('Automatic checks', 'Every ' . $scheduler->checkPeriod . ' hours'); + } else { + $this->row('Automatic checks', 'Disabled'); + } + + if ( isset($scheduler->throttleRedundantChecks) ) { + if ( $scheduler->throttleRedundantChecks && ($scheduler->checkPeriod > 0) ) { + $this->row( + 'Throttling', + sprintf( + 'Enabled. If an update is already available, check for updates every %1$d hours instead of every %2$d hours.', + $scheduler->throttledCheckPeriod, + $scheduler->checkPeriod + ) + ); + } else { + $this->row('Throttling', 'Disabled'); + } + } + echo '
'; + } + + protected function displayConfigHeader() { + //Do nothing. This should be implemented in subclasses. + } + + protected function getMetadataButton() { + return ''; + } + + private function displayStatus() { + echo '

Status

'; + echo ''; + $state = $this->updateChecker->getUpdateState(); + $checkNowButton = ''; + if ( function_exists('get_submit_button') ) { + $checkNowButton = get_submit_button( + 'Check Now', + 'secondary', + 'puc-check-now-button', + false, + array('id' => $this->updateChecker->getFilterName('check-now-button')) + ); + } + + if ( isset($state, $state->lastCheck) ) { + $this->row('Last check', $this->formatTimeWithDelta($state->lastCheck) . ' ' . $checkNowButton . $this->responseBox); + } else { + $this->row('Last check', 'Never'); + } + + $nextCheck = wp_next_scheduled($this->updateChecker->scheduler->getCronHookName()); + $this->row('Next automatic check', $this->formatTimeWithDelta($nextCheck)); + + if ( isset($state, $state->checkedVersion) ) { + $this->row('Checked version', htmlentities($state->checkedVersion)); + $this->row('Cached update', $state->update); + } + $this->row('Update checker class', htmlentities(get_class($this->updateChecker))); + echo '
'; + } + + private function displayCurrentUpdate() { + $update = $this->updateChecker->getUpdate(); + if ( $update !== null ) { + echo '

An Update Is Available

'; + echo ''; + $fields = $this->getUpdateFields(); + foreach($fields as $field) { + if ( property_exists($update, $field) ) { + $this->row(ucwords(str_replace('_', ' ', $field)), htmlentities($update->$field)); + } + } + echo '
'; + } else { + echo '

No updates currently available

'; + } + } + + protected function getUpdateFields() { + return array('version', 'download_url', 'slug',); + } + + private function formatTimeWithDelta($unixTime) { + if ( empty($unixTime) ) { + return 'Never'; + } + + $delta = time() - $unixTime; + $result = human_time_diff(time(), $unixTime); + if ( $delta < 0 ) { + $result = 'after ' . $result; + } else { + $result = $result . ' ago'; + } + $result .= ' (' . $this->formatTimestamp($unixTime) . ')'; + return $result; + } + + private function formatTimestamp($unixTime) { + return gmdate('Y-m-d H:i:s', $unixTime + (get_option('gmt_offset') * 3600)); + } + + protected function row($name, $value) { + if ( is_object($value) || is_array($value) ) { + $value = '
' . htmlentities(print_r($value, true)) . '
'; + } else if ($value === null) { + $value = 'null'; + } + printf('%1$s %2$s', $name, $value); + } + } + +endif; \ No newline at end of file diff --git a/Puc/v4/DebugBar/PluginExtension.php b/Puc/v4/DebugBar/PluginExtension.php new file mode 100644 index 0000000..7b41458 --- /dev/null +++ b/Puc/v4/DebugBar/PluginExtension.php @@ -0,0 +1,33 @@ +updateChecker->getFilterName('uid') ) { + return; + } + $this->preAjaxReqest(); + $info = $this->updateChecker->requestInfo(); + if ( $info !== null ) { + echo 'Successfully retrieved plugin info from the metadata URL:'; + echo '
', htmlentities(print_r($info, true)), '
'; + } else { + echo 'Failed to retrieve plugin info from the metadata URL.'; + } + exit; + } + } + +endif; \ No newline at end of file diff --git a/Puc/v4/DebugBar/PluginPanel.php b/Puc/v4/DebugBar/PluginPanel.php new file mode 100644 index 0000000..d29f3e7 --- /dev/null +++ b/Puc/v4/DebugBar/PluginPanel.php @@ -0,0 +1,37 @@ +row('Plugin file', htmlentities($this->updateChecker->pluginFile)); + } + + protected function getMetadataButton() { + $requestInfoButton = ''; + if ( function_exists('get_submit_button') ) { + $requestInfoButton = get_submit_button( + 'Request Info', + 'secondary', + 'puc-request-info-button', + false, + array('id' => $this->updateChecker->getFilterName('request-info-button')) + ); + } + return $requestInfoButton; + } + + protected function getUpdateFields() { + return array_merge( + parent::getUpdateFields(), + array('homepage', 'upgrade_notice', 'tested',) + ); + } + } + +endif; \ No newline at end of file diff --git a/Puc/v4/DebugBar/ThemePanel.php b/Puc/v4/DebugBar/ThemePanel.php new file mode 100644 index 0000000..647ac76 --- /dev/null +++ b/Puc/v4/DebugBar/ThemePanel.php @@ -0,0 +1,20 @@ +row('Theme directory', htmlentities($this->updateChecker->directoryName)); + } + + protected function getUpdateFields() { + return array_merge(parent::getUpdateFields(), array('details_url')); + } + } + +endif; \ No newline at end of file diff --git a/Puc/v4/Metadata.php b/Puc/v4/Metadata.php index 8f7ee36..7391e70 100644 --- a/Puc/v4/Metadata.php +++ b/Puc/v4/Metadata.php @@ -103,7 +103,7 @@ if ( !class_exists('Puc_v4_Metadata', false) ): 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); + $fields = apply_filters($this->getPrefixedFilter('retain_fields') . $from->slug, $fields); } foreach ($fields as $field) { @@ -123,8 +123,8 @@ if ( !class_exists('Puc_v4_Metadata', false) ): /** * @return string */ - protected function getFilterPrefix() { - return 'puc_'; + protected function getPrefixedFilter($tag) { + return 'puc_' . $tag . '-'; } } diff --git a/Puc/v4/Plugin/Update.php b/Puc/v4/Plugin/Update.php index c81127a..89b37e0 100644 --- a/Puc/v4/Plugin/Update.php +++ b/Puc/v4/Plugin/Update.php @@ -17,8 +17,7 @@ if ( !class_exists('Puc_v4_Plugin_Update', false) ): public $filename; //Plugin filename relative to the plugins directory. protected static $extraFields = array( - 'id', 'homepage', 'tested', 'download_url', 'upgrade_notice', - 'filename', 'translations', + 'id', 'homepage', 'tested', 'upgrade_notice', 'filename', ); /** diff --git a/Puc/v4/Plugin/UpdateChecker.php b/Puc/v4/Plugin/UpdateChecker.php index 18c2594..df95301 100644 --- a/Puc/v4/Plugin/UpdateChecker.php +++ b/Puc/v4/Plugin/UpdateChecker.php @@ -17,7 +17,6 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): public $pluginFile = ''; //Plugin filename relative to the plugins directory. Many WP APIs use this to identify plugins. public $muPluginFile = ''; //For MU plugins, the plugin filename relative to the mu-plugins directory. - private $debugBarPlugin = null; private $cachedInstalledVersion = null; /** @@ -92,12 +91,6 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): add_filter('upgrader_post_install', array($this, 'clearCachedVersion')); add_action('delete_site_transient_update_plugins', array($this, 'clearCachedVersion')); - if ( did_action('plugins_loaded') ) { - $this->initDebugBarPanel(); - } else { - add_action('plugins_loaded', array($this, 'initDebugBarPanel')); - } - parent::installHooks(); } @@ -539,19 +532,9 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): add_filter('puc_request_info_result-'.$this->slug, $callback, 10, 2); } - /** - * Initialize the update checker Debug Bar plugin/add-on thingy. - */ - public function initDebugBarPanel() { - $debugBarPlugin = dirname(__FILE__) . '/../../../debug-bar-plugin.php'; - if ( class_exists('Debug_Bar', false) && file_exists($debugBarPlugin) ) { - /** @noinspection PhpIncludeInspection */ - require_once $debugBarPlugin; - $this->debugBarPlugin = new PucDebugBarPlugin_3_2($this); - } + protected function createDebugBarExtension() { + return new Puc_v4_DebugBar_PluginExtension($this); } - - } endif; \ No newline at end of file diff --git a/Puc/v4/Theme/Update.php b/Puc/v4/Theme/Update.php index 221f947..4776c65 100644 --- a/Puc/v4/Theme/Update.php +++ b/Puc/v4/Theme/Update.php @@ -76,8 +76,8 @@ if ( !class_exists('Puc_v4_Theme_Update', false) ): return array_merge(parent::getFieldNames(), self::$extraFields); } - protected function getFilterPrefix() { - return 'tuc_'; + protected function getPrefixedFilter($tag) { + return parent::getPrefixedFilter($tag) . '_theme'; } } diff --git a/Puc/v4/Theme/UpdateChecker.php b/Puc/v4/Theme/UpdateChecker.php index 5becd39..0173d06 100644 --- a/Puc/v4/Theme/UpdateChecker.php +++ b/Puc/v4/Theme/UpdateChecker.php @@ -3,7 +3,7 @@ if ( !class_exists('Puc_v4_Theme_UpdateChecker', false) ): class Puc_v4_Theme_UpdateChecker extends Puc_v4_UpdateChecker { - protected $filterPrefix = 'tuc_'; + protected $filterSuffix = 'theme'; protected $updateClass = 'Puc_v4_Theme_Update'; protected $updateTransient = 'update_themes'; protected $translationType = 'theme'; @@ -63,7 +63,7 @@ if ( !class_exists('Puc_v4_Theme_UpdateChecker', false) ): $installedVersion = $this->getInstalledVersion(); $queryArgs['installed_version'] = ($installedVersion !== null) ? $installedVersion : ''; - $queryArgs = apply_filters($this->filterPrefix . 'request_update_query_args-' . $this->slug, $queryArgs); + $queryArgs = apply_filters($this->getFilterName('request_update_query_args'), $queryArgs); //Various options for the wp_remote_get() call. Plugins can filter these, too. $options = array( @@ -72,7 +72,7 @@ if ( !class_exists('Puc_v4_Theme_UpdateChecker', false) ): 'Accept' => 'application/json' ), ); - $options = apply_filters($this->filterPrefix . 'request_update_options-' . $this->slug, $options); + $options = apply_filters($this->getFilterName('request_update_options'), $options); $url = $this->metadataUrl; if ( !empty($queryArgs) ){ @@ -98,13 +98,17 @@ if ( !class_exists('Puc_v4_Theme_UpdateChecker', false) ): } $themeUpdate = apply_filters( - $this->filterPrefix . 'request_update_result-' . $this->slug, + $this->getFilterName('request_update_result'), $themeUpdate, $result ); return $themeUpdate; } + public function userCanInstallUpdates() { + return current_user_can('update_themes'); + } + /** * Get the currently installed version of the plugin or theme. * @@ -135,6 +139,11 @@ if ( !class_exists('Puc_v4_Theme_UpdateChecker', false) ): public function isBeingUpgraded($upgrader = null) { return $this->upgraderStatus->isThemeBeingUpgraded($this->stylesheet, $upgrader); } + + protected function createDebugBarExtension() { + return new Puc_v4_DebugBar_Extension($this, 'Puc_v4_DebugBar_ThemePanel'); + } + } endif; \ No newline at end of file diff --git a/Puc/v4/UpdateChecker.php b/Puc/v4/UpdateChecker.php index 069d2ee..051e9a8 100644 --- a/Puc/v4/UpdateChecker.php +++ b/Puc/v4/UpdateChecker.php @@ -3,7 +3,7 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): abstract class Puc_v4_UpdateChecker { - protected $filterPrefix = 'puc_'; + protected $filterSuffix = ''; protected $updateClass = ''; protected $updateTransient = ''; protected $translationType = ''; //"plugin" or "theme". @@ -61,10 +61,10 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): 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_' ) { + if ( $this->filterSuffix === '' ) { $this->optionName = 'external_updates-' . $this->slug; } else { - $this->optionName = $this->filterPrefix . 'external_updates-' . $this->slug; + $this->optionName = $this->getFilterName('external_updates'); } } @@ -111,10 +111,24 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): $this->metadataHost = @parse_url($this->metadataUrl, PHP_URL_HOST); add_filter('http_request_host_is_external', array($this, 'allowMetadataHost'), 10, 2); + //DebugBar integration. + if ( did_action('plugins_loaded') ) { + $this->maybeInitDebugBar(); + } else { + add_action('plugins_loaded', array($this, 'maybeInitDebugBar')); + } + //TODO: Debugbar //TODO: Utility functions for adding filters. } + /** + * Check if the current user has the required permissions to install updates. + * + * @return bool + */ + abstract public function userCanInstallUpdates(); + /** * Explicitly allow HTTP requests to the metadata URL. * @@ -277,20 +291,20 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): if ( !isset($result['response']['code']) ) { return new WP_Error( - $this->filterPrefix . 'no_response_code', + 'puc_no_response_code', 'wp_remote_get() returned an unexpected result.' ); } if ( $result['response']['code'] !== 200 ) { return new WP_Error( - $this->filterPrefix . 'unexpected_response_code', + 'puc_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 new WP_Error('puc_empty_response', 'The metadata file appears to be empty.'); } return true; @@ -329,7 +343,11 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): * @return string */ public function getFilterName($baseTag) { - return $this->filterPrefix . $baseTag . '-' . $this->slug; + $name = 'puc_' . $baseTag; + if ($this->filterSuffix !== '') { + $name .= '_' . $this->filterSuffix; + } + return $name . '-' . $this->slug; } /** @@ -668,6 +686,25 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): //Assume it's fine. return false; } + + /* ------------------------------------------------------------------- + * DebugBar integration + * ------------------------------------------------------------------- + */ + + /** + * Initialize the update checker Debug Bar plugin/add-on thingy. + */ + public function maybeInitDebugBar() { + if ( class_exists('Debug_Bar', false) && file_exists(dirname(__FILE__ . '/DebugBar')) ) { + $this->createDebugBarExtension(); + } + } + + protected function createDebugBarExtension() { + return new Puc_v4_DebugBar_Extension($this); + } + } endif; \ No newline at end of file diff --git a/css/puc-debug-bar.css b/css/puc-debug-bar.css index 9675685..d2ddca3 100644 --- a/css/puc-debug-bar.css +++ b/css/puc-debug-bar.css @@ -1,4 +1,4 @@ -.puc-debug-bar-panel pre { +.puc-debug-bar-panel-v4 pre { margin-top: 0; } diff --git a/debug-bar-panel.php b/debug-bar-panel.php deleted file mode 100644 index 00ef814..0000000 --- a/debug-bar-panel.php +++ /dev/null @@ -1,146 +0,0 @@ -'; - - public function __construct($updateChecker) { - $this->updateChecker = $updateChecker; - $title = sprintf( - 'PUC (%s)', - esc_attr($this->updateChecker->slug), - $this->updateChecker->slug - ); - parent::Debug_Bar_Panel($title); - } - - public function render() { - printf( - '
', - esc_attr($this->updateChecker->slug), - esc_attr(wp_create_nonce('puc-ajax')) - ); - - $this->displayConfiguration(); - $this->displayStatus(); - $this->displayCurrentUpdate(); - - echo '
'; - } - - private function displayConfiguration() { - echo '

Configuration

'; - echo ''; - $this->row('Plugin file', htmlentities($this->updateChecker->pluginFile)); - $this->row('Slug', htmlentities($this->updateChecker->slug)); - $this->row('DB option', htmlentities($this->updateChecker->optionName)); - - $requestInfoButton = ''; - if ( function_exists('get_submit_button') ) { - $requestInfoButton = get_submit_button('Request Info', 'secondary', 'puc-request-info-button', false, array('id' => 'puc-request-info-button-' . $this->updateChecker->slug)); - } - $this->row('Metadata URL', htmlentities($this->updateChecker->metadataUrl) . ' ' . $requestInfoButton . $this->responseBox); - - $scheduler = $this->updateChecker->scheduler; - if ( $scheduler->checkPeriod > 0 ) { - $this->row('Automatic checks', 'Every ' . $scheduler->checkPeriod . ' hours'); - } else { - $this->row('Automatic checks', 'Disabled'); - } - - if ( isset($scheduler->throttleRedundantChecks) ) { - if ( $scheduler->throttleRedundantChecks && ($scheduler->checkPeriod > 0) ) { - $this->row( - 'Throttling', - sprintf( - 'Enabled. If an update is already available, check for updates every %1$d hours instead of every %2$d hours.', - $scheduler->throttledCheckPeriod, - $scheduler->checkPeriod - ) - ); - } else { - $this->row('Throttling', 'Disabled'); - } - } - echo '
'; - } - - private function displayStatus() { - echo '

Status

'; - echo ''; - $state = $this->updateChecker->getUpdateState(); - $checkNowButton = ''; - if ( function_exists('get_submit_button') ) { - $checkNowButton = get_submit_button('Check Now', 'secondary', 'puc-check-now-button', false, array('id' => 'puc-check-now-button-' . $this->updateChecker->slug)); - } - - if ( isset($state, $state->lastCheck) ) { - $this->row('Last check', $this->formatTimeWithDelta($state->lastCheck) . ' ' . $checkNowButton . $this->responseBox); - } else { - $this->row('Last check', 'Never'); - } - - $nextCheck = wp_next_scheduled($this->updateChecker->scheduler->getCronHookName()); - $this->row('Next automatic check', $this->formatTimeWithDelta($nextCheck)); - - if ( isset($state, $state->checkedVersion) ) { - $this->row('Checked version', htmlentities($state->checkedVersion)); - $this->row('Cached update', $state->update); - } - $this->row('Update checker class', htmlentities(get_class($this->updateChecker))); - echo '
'; - } - - private function displayCurrentUpdate() { - $update = $this->updateChecker->getUpdate(); - if ( $update !== null ) { - echo '

An Update Is Available

'; - echo ''; - $fields = array('version', 'download_url', 'slug', 'homepage', 'upgrade_notice'); - foreach($fields as $field) { - $this->row(ucwords(str_replace('_', ' ', $field)), htmlentities($update->$field)); - } - echo '
'; - } else { - echo '

No updates currently available

'; - } - } - - private function formatTimeWithDelta($unixTime) { - if ( empty($unixTime) ) { - return 'Never'; - } - - $delta = time() - $unixTime; - $result = human_time_diff(time(), $unixTime); - if ( $delta < 0 ) { - $result = 'after ' . $result; - } else { - $result = $result . ' ago'; - } - $result .= ' (' . $this->formatTimestamp($unixTime) . ')'; - return $result; - } - - private function formatTimestamp($unixTime) { - return gmdate('Y-m-d H:i:s', $unixTime + (get_option('gmt_offset') * 3600)); - } - - private function row($name, $value) { - if ( is_object($value) || is_array($value) ) { - $value = '
' . htmlentities(print_r($value, true)) . '
'; - } else if ($value === null) { - $value = 'null'; - } - printf('%1$s %2$s', $name, $value); - } -} - -} diff --git a/debug-bar-plugin.php b/debug-bar-plugin.php deleted file mode 100644 index ed085dc..0000000 --- a/debug-bar-plugin.php +++ /dev/null @@ -1,102 +0,0 @@ -updateChecker = $updateChecker; - - add_filter('debug_bar_panels', array($this, 'addDebugBarPanel')); - add_action('debug_bar_enqueue_scripts', array($this, 'enqueuePanelDependencies')); - - add_action('wp_ajax_puc_debug_check_now', array($this, 'ajaxCheckNow')); - add_action('wp_ajax_puc_debug_request_info', array($this, 'ajaxRequestInfo')); - } - - /** - * Register the PUC Debug Bar panel. - * - * @param array $panels - * @return array - */ - public function addDebugBarPanel($panels) { - require_once dirname(__FILE__) . '/debug-bar-panel.php'; - if ( $this->updateChecker->userCanInstallUpdates() && class_exists('PluginUpdateCheckerPanel_3_2', false) ) { - $panels[] = new PluginUpdateCheckerPanel_3_2($this->updateChecker); - } - return $panels; - } - - /** - * Enqueue our Debug Bar scripts and styles. - */ - public function enqueuePanelDependencies() { - wp_enqueue_style( - 'puc-debug-bar-style', - plugins_url( "/css/puc-debug-bar.css", __FILE__ ), - array('debug-bar'), - '20130927' - ); - - wp_enqueue_script( - 'puc-debug-bar-js', - plugins_url( "/js/debug-bar.js", __FILE__ ), - array('jquery'), - '20121026' - ); - } - - /** - * Run an update check and output the result. Useful for making sure that - * the update checking process works as expected. - */ - public function ajaxCheckNow() { - if ( $_POST['slug'] !== $this->updateChecker->slug ) { - return; - } - $this->preAjaxReqest(); - $update = $this->updateChecker->checkForUpdates(); - if ( $update !== null ) { - echo "An update is available:"; - echo '
', htmlentities(print_r($update, true)), '
'; - } else { - echo 'No updates found.'; - } - exit; - } - - /** - * Request plugin info and output it. - */ - public function ajaxRequestInfo() { - if ( $_POST['slug'] !== $this->updateChecker->slug ) { - return; - } - $this->preAjaxReqest(); - $info = $this->updateChecker->requestInfo(); - if ( $info !== null ) { - echo 'Successfully retrieved plugin info from the metadata URL:'; - echo '
', htmlentities(print_r($info, true)), '
'; - } else { - echo 'Failed to retrieve plugin info from the metadata URL.'; - } - exit; - } - - /** - * Check access permissions and enable error display (for debugging). - */ - private function preAjaxReqest() { - if ( !$this->updateChecker->userCanInstallUpdates() ) { - die('Access denied'); - } - check_ajax_referer('puc-ajax'); - - error_reporting(E_ALL); - @ini_set('display_errors','On'); - } -} - -} \ No newline at end of file diff --git a/js/debug-bar.js b/js/debug-bar.js index 758ee79..d73512a 100644 --- a/js/debug-bar.js +++ b/js/debug-bar.js @@ -2,7 +2,7 @@ jQuery(function($) { function runAjaxAction(button, action) { button = $(button); - var panel = button.closest('.puc-debug-bar-panel'); + var panel = button.closest('.puc-debug-bar-panel-v4'); var responseBox = button.closest('td').find('.puc-ajax-response'); responseBox.text('Processing...').show(); @@ -10,7 +10,7 @@ jQuery(function($) { ajaxurl, { action : action, - slug : panel.data('slug'), + uid : panel.data('uid'), _wpnonce: panel.data('nonce') }, function(data) { @@ -20,12 +20,12 @@ jQuery(function($) { ); } - $('.puc-debug-bar-panel input[name="puc-check-now-button"]').click(function() { + $('.puc-debug-bar-panel-v4 input[name="puc-check-now-button"]').click(function() { runAjaxAction(this, 'puc_debug_check_now'); return false; }); - $('.puc-debug-bar-panel input[name="puc-request-info-button"]').click(function() { + $('.puc-debug-bar-panel-v4 input[name="puc-request-info-button"]').click(function() { runAjaxAction(this, 'puc_debug_request_info'); return false; }); @@ -34,19 +34,19 @@ jQuery(function($) { // Debug Bar uses the panel class name as part of its link and container IDs. This means we can // end up with multiple identical IDs if more than one plugin uses the update checker library. // Fix it by replacing the class name with the plugin slug. - var panels = $('#debug-menu-targets').find('.puc-debug-bar-panel'); - panels.each(function(index) { + var panels = $('#debug-menu-targets').find('.puc-debug-bar-panel-v4'); + panels.each(function() { var panel = $(this); - var slug = panel.data('slug'); + var uid = panel.data('uid'); var target = panel.closest('.debug-menu-target'); //Change the panel wrapper ID. - target.attr('id', 'debug-menu-target-puc-' + slug); + target.attr('id', 'debug-menu-target-puc-' + uid); //Change the menu link ID as well and point it at the new target ID. - $('#puc-debug-menu-link-' + panel.data('slug')) + $('#puc-debug-menu-link-' + uid) .closest('.debug-menu-link') - .attr('id', 'debug-menu-link-puc-' + slug) + .attr('id', 'debug-menu-link-puc-' + uid) .attr('href', '#' + target.attr('id')); }); }); \ No newline at end of file From 0cd1c51c579b1bb83b3384917040e1c3601eab7f Mon Sep 17 00:00:00 2001 From: Yahnis Elsts Date: Sat, 17 Dec 2016 19:04:52 +0200 Subject: [PATCH 07/44] Autoload Parsedown and the readme parser. Refactor requestUpdate() filtering. Rename getFilterName to getUniqueName because it's used for more than just filter names. Added a couple of utility methods. --- Puc/v4/Autoloader.php | 16 +++++ Puc/v4/DebugBar/Extension.php | 2 +- Puc/v4/DebugBar/Panel.php | 8 +-- Puc/v4/DebugBar/PluginExtension.php | 2 +- Puc/v4/DebugBar/PluginPanel.php | 2 +- Puc/v4/GitHub/PluginChecker.php | 11 +-- Puc/v4/Plugin/UpdateChecker.php | 17 +++-- Puc/v4/Scheduler.php | 4 +- Puc/v4/Theme/UpdateChecker.php | 64 +++++++++++++---- Puc/v4/UpdateChecker.php | 103 ++++++++++++++++------------ 10 files changed, 146 insertions(+), 83 deletions(-) diff --git a/Puc/v4/Autoloader.php b/Puc/v4/Autoloader.php index 1d8a83d..2067531 100644 --- a/Puc/v4/Autoloader.php +++ b/Puc/v4/Autoloader.php @@ -4,15 +4,31 @@ class Puc_v4_Autoloader { private $prefix = ''; private $rootDir = ''; + private $staticMap; + public function __construct() { $this->rootDir = dirname(__FILE__) . '/'; $nameParts = explode('_', __CLASS__, 3); $this->prefix = $nameParts[0] . '_' . $nameParts[1] . '_'; + $this->staticMap = array( + 'PucReadmeParser' => 'vendor/readme-parser.php', + 'Parsedown' => 'vendor/ParsedownLegacy.php', + ); + if ( version_compare(PHP_VERSION, '5.3.0', '>=') ) { + $this->staticMap['Parsedown'] = 'vendor/Parsedown.php'; + } + spl_autoload_register(array($this, 'autoload')); } public function autoload($className) { + if ( isset($this->staticMap[$className]) && file_exists($this->rootDir . $this->staticMap[$className]) ) { + /** @noinspection PhpIncludeInspection */ + include ($this->rootDir . $this->staticMap[$className]); + return; + } + if (strpos($className, $this->prefix) === 0) { $path = substr($className, strlen($this->prefix)); $path = str_replace('_', '/', $path); diff --git a/Puc/v4/DebugBar/Extension.php b/Puc/v4/DebugBar/Extension.php index a307952..40e6562 100644 --- a/Puc/v4/DebugBar/Extension.php +++ b/Puc/v4/DebugBar/Extension.php @@ -55,7 +55,7 @@ if ( !class_exists('Puc_v4_DebugBar_Extension', false) ): * the update checking process works as expected. */ public function ajaxCheckNow() { - if ( $_POST['uid'] !== $this->updateChecker->getFilterName('uid') ) { + if ( $_POST['uid'] !== $this->updateChecker->getUniqueName('uid') ) { return; } $this->preAjaxReqest(); diff --git a/Puc/v4/DebugBar/Panel.php b/Puc/v4/DebugBar/Panel.php index 2faa93d..19fa39d 100644 --- a/Puc/v4/DebugBar/Panel.php +++ b/Puc/v4/DebugBar/Panel.php @@ -12,7 +12,7 @@ if ( !class_exists('Puc_v4_DebugBar_Panel', false) && class_exists('Debug_Bar_Pa $this->updateChecker = $updateChecker; $title = sprintf( 'PUC (%s)', - esc_attr($this->updateChecker->getFilterName('uid')), + esc_attr($this->updateChecker->getUniqueName('uid')), $this->updateChecker->slug ); parent::__construct($title); @@ -21,9 +21,9 @@ if ( !class_exists('Puc_v4_DebugBar_Panel', false) && class_exists('Debug_Bar_Pa public function render() { printf( '
', - esc_attr($this->updateChecker->getFilterName('debug-bar-panel')), + esc_attr($this->updateChecker->getUniqueName('debug-bar-panel')), esc_attr($this->updateChecker->slug), - esc_attr($this->updateChecker->getFilterName('uid')), + esc_attr($this->updateChecker->getUniqueName('uid')), esc_attr(wp_create_nonce('puc-ajax')) ); @@ -87,7 +87,7 @@ if ( !class_exists('Puc_v4_DebugBar_Panel', false) && class_exists('Debug_Bar_Pa 'secondary', 'puc-check-now-button', false, - array('id' => $this->updateChecker->getFilterName('check-now-button')) + array('id' => $this->updateChecker->getUniqueName('check-now-button')) ); } diff --git a/Puc/v4/DebugBar/PluginExtension.php b/Puc/v4/DebugBar/PluginExtension.php index 7b41458..f37b185 100644 --- a/Puc/v4/DebugBar/PluginExtension.php +++ b/Puc/v4/DebugBar/PluginExtension.php @@ -15,7 +15,7 @@ if ( !class_exists('Puc_v4_DebugBar_PluginExtension', false) ): * Request plugin info and output it. */ public function ajaxRequestInfo() { - if ( $_POST['uid'] !== $this->updateChecker->getFilterName('uid') ) { + if ( $_POST['uid'] !== $this->updateChecker->getUniqueName('uid') ) { return; } $this->preAjaxReqest(); diff --git a/Puc/v4/DebugBar/PluginPanel.php b/Puc/v4/DebugBar/PluginPanel.php index d29f3e7..74d5283 100644 --- a/Puc/v4/DebugBar/PluginPanel.php +++ b/Puc/v4/DebugBar/PluginPanel.php @@ -20,7 +20,7 @@ if ( !class_exists('Puc_v4_DebugBar_PluginPanel', false) ): 'secondary', 'puc-request-info-button', false, - array('id' => $this->updateChecker->getFilterName('request-info-button')) + array('id' => $this->updateChecker->getUniqueName('request-info-button')) ); } return $requestInfoButton; diff --git a/Puc/v4/GitHub/PluginChecker.php b/Puc/v4/GitHub/PluginChecker.php index 659bea7..ecbfe75 100644 --- a/Puc/v4/GitHub/PluginChecker.php +++ b/Puc/v4/GitHub/PluginChecker.php @@ -129,7 +129,7 @@ class Puc_v4_GitHub_PluginChecker extends Puc_v4_Plugin_UpdateChecker { } } - $info = apply_filters('puc_request_info_result-' . $this->slug, $info, null); + $info = apply_filters($this->getUniqueName('request_info_result'), $info, null); return $info; } @@ -238,10 +238,7 @@ class Puc_v4_GitHub_PluginChecker extends Puc_v4_Plugin_UpdateChecker { * @return string */ protected function parseMarkdown($markdown) { - if ( !class_exists('Parsedown', false) ) { - require_once(dirname(__FILE__) . '/vendor/Parsedown' . (version_compare(PHP_VERSION, '5.3.0', '>=') ? '' : 'Legacy') . '.php'); - } - + /** @noinspection PhpUndefinedClassInspection */ $instance = Parsedown::instance(); return $instance->text($markdown); } @@ -416,10 +413,6 @@ class Puc_v4_GitHub_PluginChecker extends Puc_v4_Plugin_UpdateChecker { } protected function parseReadme($content) { - //TODO: Autoload this and Parsedown. - if ( !class_exists('PucReadmeParser', false) ) { - require_once(dirname(__FILE__) . '/vendor/readme-parser.php'); - } $parser = new PucReadmeParser(); return $parser->parse_readme_contents($content); } diff --git a/Puc/v4/Plugin/UpdateChecker.php b/Puc/v4/Plugin/UpdateChecker.php index df95301..11376f7 100644 --- a/Puc/v4/Plugin/UpdateChecker.php +++ b/Puc/v4/Plugin/UpdateChecker.php @@ -154,7 +154,7 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): * * @uses PluginUpdateChecker::requestInfo() * - * @return Puc_v4_Plugin_Update An instance of PluginUpdate, or NULL when no updates are available. + * @return Puc_v4_Update An instance of Plugin_Update, or NULL when no updates are available. */ public function requestUpdate() { //For the sake of simplicity, this function just calls requestInfo() @@ -165,8 +165,7 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): } $update = Puc_v4_Plugin_Update::fromPluginInfo($pluginInfo); - //Keep only those translation updates that apply to this site. - $update->translations = $this->filterApplicableTranslations($update->translations); + $update = $this->filterUpdateResult($update); return $update; } @@ -244,7 +243,7 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): } $pluginInfo = $this->requestInfo(); - $pluginInfo = apply_filters($this->getFilterName('pre_inject_info'), $pluginInfo); + $pluginInfo = apply_filters($this->getUniqueName('pre_inject_info'), $pluginInfo); if ( $pluginInfo ) { return $pluginInfo->toWpFormat(); } @@ -494,7 +493,7 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): * @return void */ public function addQueryArgFilter($callback){ - add_filter('puc_request_info_query_args-'.$this->slug, $callback); + $this->addFilter('request_info_query_args', $callback); } /** @@ -509,8 +508,8 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): * @param callable $callback * @return void */ - public function addHttpRequestArgFilter($callback){ - add_filter('puc_request_info_options-'.$this->slug, $callback); + public function addHttpRequestArgFilter($callback) { + $this->addFilter('request_info_options', $callback); } /** @@ -528,8 +527,8 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): * @param callable $callback * @return void */ - public function addResultFilter($callback){ - add_filter('puc_request_info_result-'.$this->slug, $callback, 10, 2); + public function addResultFilter($callback) { + $this->addFilter('request_info_result', $callback, 10, 2); } protected function createDebugBarExtension() { diff --git a/Puc/v4/Scheduler.php b/Puc/v4/Scheduler.php index ee030ed..f8332d1 100644 --- a/Puc/v4/Scheduler.php +++ b/Puc/v4/Scheduler.php @@ -31,7 +31,7 @@ if ( !class_exists('Puc_v4_Scheduler', false) ): $this->checkPeriod = $checkPeriod; //Set up the periodic update checks - $this->cronHook = $this->updateChecker->getFilterName('cron_check_updates'); + $this->cronHook = $this->updateChecker->getUniqueName('cron_check_updates'); if ( $this->checkPeriod > 0 ){ //Trigger the check via Cron. @@ -102,7 +102,7 @@ if ( !class_exists('Puc_v4_Scheduler', false) ): //Let plugin authors substitute their own algorithm. $shouldCheck = apply_filters( - $this->updateChecker->getFilterName('check_now'), + $this->updateChecker->getUniqueName('check_now'), $shouldCheck, (!empty($state) && isset($state->lastCheck)) ? $state->lastCheck : 0, $this->checkPeriod diff --git a/Puc/v4/Theme/UpdateChecker.php b/Puc/v4/Theme/UpdateChecker.php index 0173d06..cc17612 100644 --- a/Puc/v4/Theme/UpdateChecker.php +++ b/Puc/v4/Theme/UpdateChecker.php @@ -32,11 +32,6 @@ if ( !class_exists('Puc_v4_Theme_UpdateChecker', false) ): $checkPeriod, $optionName ); - - add_action('admin_notices', function() { - //var_dump(get_site_transient('update_plugins')); - //var_dump(get_site_transient('update_themes')); - }); } protected function installHooks() { @@ -63,7 +58,7 @@ if ( !class_exists('Puc_v4_Theme_UpdateChecker', false) ): $installedVersion = $this->getInstalledVersion(); $queryArgs['installed_version'] = ($installedVersion !== null) ? $installedVersion : ''; - $queryArgs = apply_filters($this->getFilterName('request_update_query_args'), $queryArgs); + $queryArgs = apply_filters($this->getUniqueName('request_update_query_args'), $queryArgs); //Various options for the wp_remote_get() call. Plugins can filter these, too. $options = array( @@ -72,7 +67,7 @@ if ( !class_exists('Puc_v4_Theme_UpdateChecker', false) ): 'Accept' => 'application/json' ), ); - $options = apply_filters($this->getFilterName('request_update_options'), $options); + $options = apply_filters($this->getUniqueName('request_update_options'), $options); $url = $this->metadataUrl; if ( !empty($queryArgs) ){ @@ -97,11 +92,7 @@ if ( !class_exists('Puc_v4_Theme_UpdateChecker', false) ): ); } - $themeUpdate = apply_filters( - $this->getFilterName('request_update_result'), - $themeUpdate, - $result - ); + $themeUpdate = $this->filterUpdateResult($themeUpdate, $result); return $themeUpdate; } @@ -128,8 +119,6 @@ if ( !class_exists('Puc_v4_Theme_UpdateChecker', false) ): return new Puc_v4_Scheduler($this, $checkPeriod, array('load-themes.php')); } - //TODO: Various add*filter utilities for backwards compatibility. - /** * Is there an update being installed right now for this theme? * @@ -144,6 +133,53 @@ if ( !class_exists('Puc_v4_Theme_UpdateChecker', false) ): return new Puc_v4_DebugBar_Extension($this, 'Puc_v4_DebugBar_ThemePanel'); } + /** + * Register a callback for filtering query arguments. + * + * The callback function should take one argument - an associative array of query arguments. + * It should return a modified array of query arguments. + * + * @param callable $callback + * @return void + */ + public function addQueryArgFilter($callback){ + $this->addFilter('request_update_query_args', $callback); + } + + /** + * Register a callback for filtering arguments passed to wp_remote_get(). + * + * The callback function should take one argument - an associative array of arguments - + * and return a modified array or arguments. See the WP documentation on wp_remote_get() + * for details on what arguments are available and how they work. + * + * @uses add_filter() This method is a convenience wrapper for add_filter(). + * + * @param callable $callback + * @return void + */ + public function addHttpRequestArgFilter($callback) { + $this->addFilter('request_update_options', $callback); + } + + /** + * Register a callback for filtering theme updates retrieved from the external API. + * + * The callback function should take two arguments. If the theme update was retrieved + * successfully, the first argument passed will be an instance of Theme_Update. Otherwise, + * it will be NULL. The second argument will be the corresponding return value of + * wp_remote_get (see WP docs for details). + * + * The callback function should return a new or modified instance of Theme_Update or NULL. + * + * @uses add_filter() This method is a convenience wrapper for add_filter(). + * + * @param callable $callback + * @return void + */ + public function addResultFilter($callback) { + $this->addFilter('request_update_result', $callback, 10, 2); + } } endif; \ No newline at end of file diff --git a/Puc/v4/UpdateChecker.php b/Puc/v4/UpdateChecker.php index 051e9a8..efe7aa9 100644 --- a/Puc/v4/UpdateChecker.php +++ b/Puc/v4/UpdateChecker.php @@ -64,7 +64,7 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): if ( $this->filterSuffix === '' ) { $this->optionName = 'external_updates-' . $this->slug; } else { - $this->optionName = $this->getFilterName('external_updates'); + $this->optionName = $this->getUniqueName('external_updates'); } } @@ -117,9 +117,6 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): } else { add_action('plugins_loaded', array($this, 'maybeInitDebugBar')); } - - //TODO: Debugbar - //TODO: Utility functions for adding filters. } /** @@ -194,10 +191,6 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): $this->setUpdateState($state); //Save before checking in case something goes wrong $state->update = $this->requestUpdate(); - if ( isset($state->update, $state->update->translations) ) { - //TODO: Should this be called in requestUpdate, like PluginUpdater does? - $state->update->translations = $this->filterApplicableTranslations($state->update->translations); - } $this->setUpdateState($state); return $this->getUpdate(); @@ -274,10 +267,31 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): /** * Retrieve the latest update (if any) from the configured API endpoint. * + * Subclasses should run the update through filterUpdateResult before returning it. + * * @return Puc_v4_Update An instance of Update, or NULL when no updates are available. */ abstract public function requestUpdate(); + /** + * Filter the result of a requestUpdate() call. + * + * @param Puc_v4_Update $update + * @param array|WP_Error|null $httpResult The value returned by wp_remote_get(), if any. + * @return Puc_v4_Update + */ + protected function filterUpdateResult($update, $httpResult = null) { + //Let plugins/themes modify the update. + $update = apply_filters($this->getUniqueName('request_update_result'), $update, $httpResult); + + if ( isset($update, $update->translations) ) { + //Keep only those translation updates that apply to this site. + $update->translations = $this->filterApplicableTranslations($update->translations); + } + + return $update; + } + /** * Check if $result is a successful update API response. * @@ -317,39 +331,6 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): */ abstract public function getInstalledVersion(); - /** - * Register a callback for one of the update checker filters. - * - * Identical to add_filter(), except it automatically adds the "puc_"/"tuc_" prefix - * and the "-$slug" suffix to the filter name. For example, "request_info_result" - * becomes "puc_request_info_result-your_plugin_slug". - * - * @param string $tag - * @param callable $callback - * @param int $priority - * @param int $acceptedArgs - */ - public function addFilter($tag, $callback, $priority = 10, $acceptedArgs = 1) { - add_filter($this->getFilterName($tag), $callback, $priority, $acceptedArgs); - } - - /** - * Get the full name of an update checker filter or action. - * - * This method adds the "puc_"/"tuc_" prefix and the "-$slug" suffix to the filter name. - * For example, "pre_inject_update" becomes "puc_pre_inject_update-plugin-slug". - * - * @param string $baseTag - * @return string - */ - public function getFilterName($baseTag) { - $name = 'puc_' . $baseTag; - if ($this->filterSuffix !== '') { - $name .= '_' . $this->filterSuffix; - } - return $name . '-' . $this->slug; - } - /** * Trigger a PHP error, but only when $debugMode is enabled. * @@ -362,6 +343,44 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): } } + /** + * Get the full name of an update checker filter, action or DB entry. + * + * This method adds the "puc_" prefix and the "-$slug" suffix to the filter name. + * For example, "pre_inject_update" becomes "puc_pre_inject_update-plugin-slug". + * + * @param string $baseTag + * @return string + */ + public function getUniqueName($baseTag) { + $name = 'puc_' . $baseTag; + if ($this->filterSuffix !== '') { + $name .= '_' . $this->filterSuffix; + } + return $name . '-' . $this->slug; + } + + /* ------------------------------------------------------------------- + * PUC filters and filter utilities + * ------------------------------------------------------------------- + */ + + /** + * Register a callback for one of the update checker filters. + * + * Identical to add_filter(), except it automatically adds the "puc_" prefix + * and the "-$slug" suffix to the filter name. For example, "request_info_result" + * becomes "puc_request_info_result-your_plugin_slug". + * + * @param string $tag + * @param callable $callback + * @param int $priority + * @param int $acceptedArgs + */ + public function addFilter($tag, $callback, $priority = 10, $acceptedArgs = 1) { + add_filter($this->getUniqueName($tag), $callback, $priority, $acceptedArgs); + } + /* ------------------------------------------------------------------- * Inject updates * ------------------------------------------------------------------- @@ -383,7 +402,7 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): if ( !empty($update) ) { //Let plugins filter the update info before it's passed on to WordPress. - $update = apply_filters($this->getFilterName('pre_inject_update'), $update); + $update = apply_filters($this->getUniqueName('pre_inject_update'), $update); $updates = $this->addUpdateToList($updates, $update->toWpFormat()); } else { //Clean up any stale update info. From 8c7b3f80d6514c533f325ea2ab3a4b9ecc7ef2dd Mon Sep 17 00:00:00 2001 From: Yahnis Elsts Date: Mon, 19 Dec 2016 18:41:37 +0200 Subject: [PATCH 08/44] Improve upgrade-in-progress detection for themes. In WP 4.6, AJAX-based upgrades use a different skin that was previously not supported. --- Puc/v4/UpgraderStatus.php | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/Puc/v4/UpgraderStatus.php b/Puc/v4/UpgraderStatus.php index 94c32a2..e199bd9 100644 --- a/Puc/v4/UpgraderStatus.php +++ b/Puc/v4/UpgraderStatus.php @@ -14,7 +14,7 @@ if ( !class_exists('Puc_v4_UpgraderStatus', false) ): public function __construct() { //Keep track of which plugin/theme WordPress is currently upgrading. - add_filter('upgrader_pre_install', array($this, 'setUpgradedPlugin'), 10, 2); + add_filter('upgrader_pre_install', array($this, 'setUpgradedThing'), 10, 2); add_filter('upgrader_package_options', array($this, 'setUpgradedPluginFromOptions'), 10, 1); add_filter('upgrader_post_install', array($this, 'clearUpgradedThing'), 10, 1); add_action('upgrader_process_complete', array($this, 'clearUpgradedThing'), 10, 1); @@ -89,7 +89,9 @@ if ( !class_exists('Puc_v4_UpgraderStatus', false) ): $themeDirectoryName = null; $skin = $upgrader->skin; - if ( $skin instanceof Plugin_Upgrader_Skin ) { + if ( isset($skin->theme_info) && ($skin->theme_info instanceof WP_Theme) ) { + $themeDirectoryName = $skin->theme_info->get_stylesheet(); + } elseif ( $skin instanceof Plugin_Upgrader_Skin ) { if ( isset($skin->plugin) && is_string($skin->plugin) && ($skin->plugin !== '') ) { $pluginFile = $skin->plugin; } @@ -97,10 +99,6 @@ if ( !class_exists('Puc_v4_UpgraderStatus', false) ): if ( isset($skin->theme) && is_string($skin->theme) && ($skin->theme !== '') ) { $themeDirectoryName = $skin->theme; } - } elseif ( $upgrader->skin instanceof Bulk_Theme_Upgrader_Skin ) { - if ( isset($skin->theme_info) && ($skin->theme_info instanceof WP_Theme) ) { - $themeDirectoryName = $skin->theme_info->get_stylesheet(); - } } elseif ( isset($skin->plugin_info) && is_array($skin->plugin_info) ) { //This case is tricky because Bulk_Plugin_Upgrader_Skin (etc) doesn't actually store the plugin //filename anywhere. Instead, it has the plugin headers in $plugin_info. So the best we can @@ -154,10 +152,13 @@ if ( !class_exists('Puc_v4_UpgraderStatus', false) ): * @param array $hookExtra * @return mixed Returns $input unaltered. */ - public function setUpgradedPlugin($input, $hookExtra) { - if (!empty($hookExtra['plugin']) && is_string($hookExtra['plugin'])) { + public function setUpgradedThing($input, $hookExtra) { + if ( !empty($hookExtra['plugin']) && is_string($hookExtra['plugin']) ) { $this->currentId = $hookExtra['plugin']; $this->currentType = 'plugin'; + } elseif ( !empty($hookExtra['theme']) && is_string($hookExtra['theme']) ) { + $this->currentId = $hookExtra['theme']; + $this->currentType = 'theme'; } else { $this->currentType = null; $this->currentId = null; @@ -172,7 +173,7 @@ if ( !class_exists('Puc_v4_UpgraderStatus', false) ): * @return array */ public function setUpgradedPluginFromOptions($options) { - if (isset($options['hook_extra']['plugin']) && is_string($options['hook_extra']['plugin'])) { + if ( isset($options['hook_extra']['plugin']) && is_string($options['hook_extra']['plugin']) ) { $this->currentType = 'plugin'; $this->currentId = $options['hook_extra']['plugin']; } else { From f1e59b183c3270ac358c1868d49e01545465fe4a Mon Sep 17 00:00:00 2001 From: Yahnis Elsts Date: Mon, 19 Dec 2016 19:05:06 +0200 Subject: [PATCH 09/44] Rename DebugBar AJAX actions to prevent conflicts with previous versions. --- Puc/v4/DebugBar/Extension.php | 4 +- Puc/v4/DebugBar/PluginExtension.php | 2 +- js/debug-bar.js | 4 +- plugin-update-checker.php | 98 ++--------------------------- 4 files changed, 9 insertions(+), 99 deletions(-) diff --git a/Puc/v4/DebugBar/Extension.php b/Puc/v4/DebugBar/Extension.php index 40e6562..60a4c30 100644 --- a/Puc/v4/DebugBar/Extension.php +++ b/Puc/v4/DebugBar/Extension.php @@ -15,7 +15,7 @@ if ( !class_exists('Puc_v4_DebugBar_Extension', false) ): add_filter('debug_bar_panels', array($this, 'addDebugBarPanel')); add_action('debug_bar_enqueue_scripts', array($this, 'enqueuePanelDependencies')); - add_action('wp_ajax_puc_debug_check_now', array($this, 'ajaxCheckNow')); + add_action('wp_ajax_puc_v4_debug_check_now', array($this, 'ajaxCheckNow')); } /** @@ -46,7 +46,7 @@ if ( !class_exists('Puc_v4_DebugBar_Extension', false) ): 'puc-debug-bar-js-v4', $this->getLibraryUrl("/js/debug-bar.js"), array('jquery'), - '20161217-3' + '20161219' ); } diff --git a/Puc/v4/DebugBar/PluginExtension.php b/Puc/v4/DebugBar/PluginExtension.php index f37b185..c33eef7 100644 --- a/Puc/v4/DebugBar/PluginExtension.php +++ b/Puc/v4/DebugBar/PluginExtension.php @@ -8,7 +8,7 @@ if ( !class_exists('Puc_v4_DebugBar_PluginExtension', false) ): public function __construct($updateChecker) { parent::__construct($updateChecker, 'Puc_v4_DebugBar_PluginPanel'); - add_action('wp_ajax_puc_debug_request_info', array($this, 'ajaxRequestInfo')); + add_action('wp_ajax_puc_v4_debug_request_info', array($this, 'ajaxRequestInfo')); } /** diff --git a/js/debug-bar.js b/js/debug-bar.js index d73512a..57baaa9 100644 --- a/js/debug-bar.js +++ b/js/debug-bar.js @@ -21,12 +21,12 @@ jQuery(function($) { } $('.puc-debug-bar-panel-v4 input[name="puc-check-now-button"]').click(function() { - runAjaxAction(this, 'puc_debug_check_now'); + runAjaxAction(this, 'puc_v4_debug_check_now'); return false; }); $('.puc-debug-bar-panel-v4 input[name="puc-request-info-button"]').click(function() { - runAjaxAction(this, 'puc_debug_request_info'); + runAjaxAction(this, 'puc_v4_debug_request_info'); return false; }); diff --git a/plugin-update-checker.php b/plugin-update-checker.php index c89ba13..beb515d 100644 --- a/plugin-update-checker.php +++ b/plugin-update-checker.php @@ -10,98 +10,8 @@ require dirname(__FILE__) . '/Puc/v4/Autoloader.php'; new Puc_v4_Autoloader(); - -if ( !class_exists('PucFactory', false) ): - -/** - * A factory that builds instances of other classes from this library. - * - * When multiple versions of the same class have been loaded (e.g. PluginUpdateChecker 1.2 - * and 1.3), this factory will always use the latest available version. Register class - * versions by calling {@link PucFactory::addVersion()}. - * - * At the moment it can only build instances of the PluginUpdateChecker class. Other classes - * are intended mainly for internal use and refer directly to specific implementations. If you - * want to instantiate one of them anyway, you can use {@link PucFactory::getLatestClassVersion()} - * to get the class name and then create it with new $class(...). - */ -class PucFactory { - protected static $classVersions = array(); - protected static $sorted = false; - - /** - * Create a new instance of PluginUpdateChecker. - * - * @see PluginUpdateChecker::__construct() - * - * @param $metadataUrl - * @param $pluginFile - * @param string $slug - * @param int $checkPeriod - * @param string $optionName - * @param string $muPluginFile - * @return Puc_v4_Plugin_UpdateChecker - */ - public static function buildUpdateChecker($metadataUrl, $pluginFile, $slug = '', $checkPeriod = 12, $optionName = '', $muPluginFile = '') { - $class = self::getLatestClassVersion('PluginUpdateChecker'); - return new $class($metadataUrl, $pluginFile, $slug, $checkPeriod, $optionName, $muPluginFile); - } - - /** - * Get the specific class name for the latest available version of a class. - * - * @param string $class - * @return string|null - */ - public static function getLatestClassVersion($class) { - if ( !self::$sorted ) { - self::sortVersions(); - } - - if ( isset(self::$classVersions[$class]) ) { - return reset(self::$classVersions[$class]); - } else { - return null; - } - } - - /** - * Sort available class versions in descending order (i.e. newest first). - */ - protected static function sortVersions() { - foreach ( self::$classVersions as $class => $versions ) { - uksort($versions, array(__CLASS__, 'compareVersions')); - self::$classVersions[$class] = $versions; - } - self::$sorted = true; - } - - protected static function compareVersions($a, $b) { - return -version_compare($a, $b); - } - - /** - * Register a version of a class. - * - * @access private This method is only for internal use by the library. - * - * @param string $generalClass Class name without version numbers, e.g. 'PluginUpdateChecker'. - * @param string $versionedClass Actual class name, e.g. 'PluginUpdateChecker_1_2'. - * @param string $version Version number, e.g. '1.2'. - */ - public static function addVersion($generalClass, $versionedClass, $version) { - if ( !isset(self::$classVersions[$generalClass]) ) { - self::$classVersions[$generalClass] = array(); - } - self::$classVersions[$generalClass][$version] = $versionedClass; - self::$sorted = false; - } -} - -endif; - //Register classes defined in this file with the factory. -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'); +Puc_v4_Factory::addVersion('Plugin_UpdateChecker', 'Puc_v4_Plugin_UpdateChecker', '4.0'); +Puc_v4_Factory::addVersion('Theme_UpdateChecker', 'Puc_v4_Theme_UpdateChecker', '4.0'); +Puc_v4_Factory::addVersion('GitHub_PluginUpdateChecker', 'Puc_v4_GitHub_PluginUpdateChecker', '4.0'); +Puc_v4_Factory::addVersion('GitHub_ThemeUpdateChecker', 'Puc_v4_GitHub_ThemeUpdateChecker', '4.0'); From 16ab2457df5eefb47547c2619a803dddbb5a38e5 Mon Sep 17 00:00:00 2001 From: Yahnis Elsts Date: Mon, 19 Dec 2016 19:26:34 +0200 Subject: [PATCH 10/44] Support themes hosted on GitHub. What should "View version 1.2.3 details" link to? Theme URI, the changelog, the release, or the repository itself? --- Puc/v4/Factory.php | 170 ++++++++++++ Puc/v4/GitHub/Api.php | 184 +++++++++++++ ...ginChecker.php => PluginUpdateChecker.php} | 250 +++--------------- Puc/v4/GitHub/ThemeUpdateChecker.php | 99 +++++++ Puc/v4/Plugin/UpdateChecker.php | 24 ++ Puc/v4/Theme/UpdateChecker.php | 19 ++ Puc/v4/UpdateChecker.php | 44 +++ 7 files changed, 571 insertions(+), 219 deletions(-) create mode 100644 Puc/v4/Factory.php create mode 100644 Puc/v4/GitHub/Api.php rename Puc/v4/GitHub/{PluginChecker.php => PluginUpdateChecker.php} (53%) create mode 100644 Puc/v4/GitHub/ThemeUpdateChecker.php diff --git a/Puc/v4/Factory.php b/Puc/v4/Factory.php new file mode 100644 index 0000000..23d13e7 --- /dev/null +++ b/Puc/v4/Factory.php @@ -0,0 +1,170 @@ +new $class(...). + */ + class Puc_v4_Factory { + protected static $classVersions = array(); + protected static $sorted = false; + + protected static $myMajorVersion = ''; + protected static $greatestCompatVersion = ''; + + /** + * Create a new instance of the update checker. + * + * @see PluginUpdateChecker::__construct() + * + * @param string $metadataUrl The URL of the metadata file, or a GitHub repository, etc. + * @param string $fullPath Full path to the main plugin file or to the theme directory. + * @param string $slug Custom slug. Defaults to the name of the main plugin file or the theme directory. + * @param int $checkPeriod How often to check for updates (in hours). + * @param string $optionName Where to store book-keeping info about update checks. + * @param string $muPluginFile The plugin filename relative to the mu-plugins directory. + * @return Puc_v4_UpdateChecker + */ + public static function buildUpdateChecker($metadataUrl, $fullPath, $slug = '', $checkPeriod = 12, $optionName = '', $muPluginFile = '') { + $fullPath = wp_normalize_path($fullPath); + $service = null; + $id = null; + + //Plugin or theme? + if ( self::isPluginFile($fullPath) ) { + $type = 'Plugin'; + $id = $fullPath; + } else { + $type = 'Theme'; + + //Get the name of the theme's directory. E.g. "wp-content/themes/foo/whatever.php" => "foo". + $themeRoot = wp_normalize_path(get_theme_root()); + $pathComponents = explode('/', substr($fullPath, strlen($themeRoot) + 1)); + $id = $pathComponents[0]; + } + + //Which hosting service does the URL point to? + $host = @parse_url($metadataUrl, PHP_URL_HOST); + $path = @parse_url($metadataUrl, PHP_URL_PATH); + //Check if the path looks like "/user-name/repository". + $usernameRepoRegex = '@^/?([^/]+?)/([^/#?&]+?)/?$@'; + if ( preg_match($usernameRepoRegex, $path) ) { + switch($host) { + case 'github.com': + $service = 'GitHub'; + break; + case 'bitbucket.com': + $service = 'BitBucket'; + break; + case 'gitlab.com': + $service = 'GitLab'; + break; + } + } + + $class = null; + if ( empty($service) ) { + //The default is to get update information from a remote JSON file. + $class = $type . '_UpdateChecker'; + } else { + $class = $service . '_' . $type . 'UpdateChecker'; + } + + if ( !isset(self::$classVersions[$class][self::$greatestCompatVersion]) ) { + trigger_error( + sprintf( + 'PUC %s does not support updates for %ss hosted on %s', + htmlentities(self::$greatestCompatVersion), + strtolower($type), + $service + ), + E_USER_ERROR + ); + return null; + } + + $class = self::$classVersions[$class][self::$greatestCompatVersion]; + return new $class($metadataUrl, $id, $slug, $checkPeriod, $optionName, $muPluginFile); + } + + protected static function isPluginFile($absolutePath) { + $pluginDir = wp_normalize_path(WP_PLUGIN_DIR); + $muPluginDir = wp_normalize_path(WPMU_PLUGIN_DIR); + $absolutePath = wp_normalize_path($absolutePath); + + return (strpos($absolutePath, $pluginDir) === 0) || (strpos($absolutePath, $muPluginDir) === 0); + } + + /** + * Get the specific class name for the latest available version of a class. + * + * @param string $class + * @return null|string + */ + public static function getLatestClassVersion($class) { + if ( !self::$sorted ) { + self::sortVersions(); + } + + if ( isset(self::$classVersions[$class]) ) { + return reset(self::$classVersions[$class]); + } else { + return null; + } + } + + /** + * Sort available class versions in descending order (i.e. newest first). + */ + protected static function sortVersions() { + foreach ( self::$classVersions as $class => $versions ) { + uksort($versions, array(__CLASS__, 'compareVersions')); + self::$classVersions[$class] = $versions; + } + self::$sorted = true; + } + + protected static function compareVersions($a, $b) { + return -version_compare($a, $b); + } + + /** + * Register a version of a class. + * + * @access private This method is only for internal use by the library. + * + * @param string $generalClass Class name without version numbers, e.g. 'PluginUpdateChecker'. + * @param string $versionedClass Actual class name, e.g. 'PluginUpdateChecker_1_2'. + * @param string $version Version number, e.g. '1.2'. + */ + public static function addVersion($generalClass, $versionedClass, $version) { + if ( empty(self::$myMajorVersion) ) { + $nameParts = explode('_', __CLASS__, 3); + self::$myMajorVersion = substr(ltrim($nameParts[1], 'v'), 0, 1); + } + + //Store the greatest version number that matches our major version. + $components = explode('.', $version); + if ( $components[0] === self::$myMajorVersion ) { + if ( empty(self::$greatestCompatVersion) || version_compare($version, self::$greatestCompatVersion, '>') ) { + self::$greatestCompatVersion = $version; + } + } + + if ( !isset(self::$classVersions[$generalClass]) ) { + self::$classVersions[$generalClass] = array(); + } + self::$classVersions[$generalClass][$version] = $versionedClass; + self::$sorted = false; + } + } + +endif; \ No newline at end of file diff --git a/Puc/v4/GitHub/Api.php b/Puc/v4/GitHub/Api.php new file mode 100644 index 0000000..7072280 --- /dev/null +++ b/Puc/v4/GitHub/Api.php @@ -0,0 +1,184 @@ +repositoryUrl = $repositoryUrl; + $this->accessToken = $accessToken; + + $path = @parse_url($repositoryUrl, PHP_URL_PATH); + if ( preg_match('@^/?(?P[^/]+?)/(?P[^/#?&]+?)/?$@', $path, $matches) ) { + $this->userName = $matches['username']; + $this->repositoryName = $matches['repository']; + } else { + throw new InvalidArgumentException('Invalid GitHub repository URL: "' . $repositoryUrl . '"'); + } + } + + /** + * Get the latest release from GitHub. + * + * @return StdClass|null + */ + public function getLatestRelease() { + $release = $this->api('/repos/:user/:repo/releases/latest'); + if ( is_wp_error($release) || !is_object($release) || !isset($release->tag_name) ) { + return null; + } + return $release; + } + + /** + * Get the tag that looks like the highest version number. + * + * @return StdClass|null + */ + public function getLatestTag() { + $tags = $this->api('/repos/:user/:repo/tags'); + + if ( is_wp_error($tags) || empty($tags) || !is_array($tags) ) { + return null; + } + + usort($tags, array($this, 'compareTagNames')); //Sort from highest to lowest. + return $tags[0]; + } + + /** + * Compare two GitHub tags as if they were version number. + * + * @param string $tag1 + * @param string $tag2 + * @return int + */ + protected function compareTagNames($tag1, $tag2) { + if ( !isset($tag1->name) ) { + return 1; + } + if ( !isset($tag2->name) ) { + return -1; + } + return -version_compare($tag1->name, $tag2->name); + } + + /** + * Get the latest commit that changed the specified file. + * + * @param string $filename + * @param string $ref Reference name (e.g. branch or tag). + * @return StdClass|null + */ + public function getLatestCommit($filename, $ref = 'master') { + $commits = $this->api( + '/repos/:user/:repo/commits', + array( + 'path' => $filename, + 'sha' => $ref, + ) + ); + if ( !is_wp_error($commits) && is_array($commits) && isset($commits[0]) ) { + return $commits[0]; + } + return null; + } + + /** + * Perform a GitHub API request. + * + * @param string $url + * @param array $queryParams + * @return mixed|WP_Error + */ + protected function api($url, $queryParams = array()) { + $variables = array( + 'user' => $this->userName, + 'repo' => $this->repositoryName, + ); + foreach ($variables as $name => $value) { + $url = str_replace('/:' . $name, '/' . urlencode($value), $url); + } + $url = 'https://api.github.com' . $url; + + if ( !empty($this->accessToken) ) { + $queryParams['access_token'] = $this->accessToken; + } + if ( !empty($queryParams) ) { + $url = add_query_arg($queryParams, $url); + } + + $response = wp_remote_get($url, array('timeout' => 10)); + if ( is_wp_error($response) ) { + return $response; + } + + $code = wp_remote_retrieve_response_code($response); + $body = wp_remote_retrieve_body($response); + if ( $code === 200 ) { + $document = json_decode($body); + return $document; + } + + return new WP_Error( + 'puc-github-http-error', + 'GitHub API error. HTTP status: ' . $code + ); + } + + /** + * Get the contents of a file from a specific branch or tag. + * + * @param string $path File name. + * @param string $ref + * @return null|string Either the contents of the file, or null if the file doesn't exist or there's an error. + */ + public function getRemoteFile($path, $ref = 'master') { + $apiUrl = '/repos/:user/:repo/contents/' . $path; + $response = $this->api($apiUrl, array('ref' => $ref)); + + if ( is_wp_error($response) || !isset($response->content) || ($response->encoding !== 'base64') ) { + return null; + } + return base64_decode($response->content); + } + + /** + * Generate a URL to download a ZIP archive of the specified branch/tag/etc. + * + * @param string $ref + * @return string + */ + public function buildArchiveDownloadUrl($ref = 'master') { + $url = sprintf( + 'https://api.github.com/repos/%1$s/%2$s/zipball/%3$s', + urlencode($this->userName), + urlencode($this->repositoryName), + urlencode($ref) + ); + if ( !empty($this->accessToken) ) { + $url = add_query_arg('access_token', $this->accessToken, $url); + } + return $url; + } + } + +endif; \ No newline at end of file diff --git a/Puc/v4/GitHub/PluginChecker.php b/Puc/v4/GitHub/PluginUpdateChecker.php similarity index 53% rename from Puc/v4/GitHub/PluginChecker.php rename to Puc/v4/GitHub/PluginUpdateChecker.php index ecbfe75..e686dee 100644 --- a/Puc/v4/GitHub/PluginChecker.php +++ b/Puc/v4/GitHub/PluginUpdateChecker.php @@ -1,17 +1,8 @@ repositoryUrl = $repositoryUrl; + parent::__construct($repositoryUrl, $pluginFile, $slug, $checkPeriod, $optionName, $muPluginFile); + } + + /** + * Set the GitHub branch to use for updates. Defaults to 'master'. + * + * @param string $branch + * @return $this + */ + public function setBranch($branch) { $this->branch = empty($branch) ? 'master' : $branch; - - $path = @parse_url($repositoryUrl, PHP_URL_PATH); - if ( preg_match('@^/?(?P[^/]+?)/(?P[^/#?&]+?)/?$@', $path, $matches) ) { - $this->userName = $matches['username']; - $this->repositoryName = $matches['repository']; - } else { - throw new InvalidArgumentException('Invalid GitHub repository URL: "' . $repositoryUrl . '"'); - } - - parent::__construct($repositoryUrl, $pluginFile, '', $checkPeriod, $optionName, $muPluginFile); + return $this; } /** @@ -57,6 +53,8 @@ class Puc_v4_GitHub_PluginChecker extends Puc_v4_Plugin_UpdateChecker { * @return Puc_v4_Plugin_Info */ public function requestInfo($unusedQueryArgs = array()) { + $api = $this->api = new Puc_v4_GitHub_Api($this->repositoryUrl, $this->accessToken); + $info = new Puc_v4_Plugin_Info(); $info->filename = $this->pluginFile; $info->slug = $this->slug; @@ -67,7 +65,7 @@ class Puc_v4_GitHub_PluginChecker extends Puc_v4_Plugin_UpdateChecker { $ref = $this->branch; if ( $this->branch === 'master' ) { //Use the latest release. - $release = $this->getLatestRelease(); + $release = $api->getLatestRelease(); if ( $release !== null ) { $ref = $release->tag_name; $info->version = ltrim($release->tag_name, 'v'); //Remove the "v" prefix from "v1.2.3". @@ -82,7 +80,7 @@ class Puc_v4_GitHub_PluginChecker extends Puc_v4_Plugin_UpdateChecker { } } else { //Failing that, use the tag with the highest version number. - $tag = $this->getLatestTag(); + $tag = $api->getLatestTag(); if ( $tag !== null ) { $ref = $tag->name; $info->version = $tag->name; @@ -92,7 +90,7 @@ class Puc_v4_GitHub_PluginChecker extends Puc_v4_Plugin_UpdateChecker { } if ( empty($info->download_url) ) { - $info->download_url = $this->buildArchiveDownloadUrl($ref); + $info->download_url = $api->buildArchiveDownloadUrl($ref); } else if ( !empty($this->accessToken) ) { $info->download_url = add_query_arg('access_token', $this->accessToken, $info->download_url); } @@ -100,7 +98,7 @@ class Puc_v4_GitHub_PluginChecker extends Puc_v4_Plugin_UpdateChecker { //Get headers from the main plugin file in this branch/tag. Its "Version" header and other metadata //are what the WordPress install will actually see after upgrading, so they take precedence over releases/tags. $mainPluginFile = basename($this->pluginFile); - $remotePlugin = $this->getRemoteFile($mainPluginFile, $ref); + $remotePlugin = $api->getRemoteFile($mainPluginFile, $ref); if ( !empty($remotePlugin) ) { $remoteHeader = $this->getFileHeader($remotePlugin); $this->setInfoFromHeader($remoteHeader, $info); @@ -123,7 +121,7 @@ class Puc_v4_GitHub_PluginChecker extends Puc_v4_Plugin_UpdateChecker { if ( empty($info->last_updated) ) { //Fetch the latest commit that changed the main plugin file and use it as the "last_updated" date. //It's reasonable to assume that every update will change the version number in that file. - $latestCommit = $this->getLatestCommit($mainPluginFile, $ref); + $latestCommit = $api->getLatestCommit($mainPluginFile, $ref); if ( $latestCommit !== null ) { $info->last_updated = $latestCommit->commit->author->date; } @@ -133,82 +131,13 @@ class Puc_v4_GitHub_PluginChecker extends Puc_v4_Plugin_UpdateChecker { return $info; } - /** - * Get the latest release from GitHub. - * - * @return StdClass|null - */ - protected function getLatestRelease() { - $releases = $this->api('/repos/:user/:repo/releases'); - if ( is_wp_error($releases) || !is_array($releases) || !isset($releases[0]) ) { - return null; - } - - $latestRelease = $releases[0]; - return $latestRelease; - } - - /** - * Get the tag that looks like the highest version number. - * - * @return StdClass|null - */ - protected function getLatestTag() { - $tags = $this->api('/repos/:user/:repo/tags'); - - if ( is_wp_error($tags) || empty($tags) || !is_array($tags) ) { - return null; - } - - usort($tags, array($this, 'compareTagNames')); //Sort from highest to lowest. - return $tags[0]; - } - - /** - * Compare two GitHub tags as if they were version number. - * - * @param string $tag1 - * @param string $tag2 - * @return int - */ - protected function compareTagNames($tag1, $tag2) { - if ( !isset($tag1->name) ) { - return 1; - } - if ( !isset($tag2->name) ) { - return -1; - } - return -version_compare($tag1->name, $tag2->name); - } - - /** - * Get the latest commit that changed the specified file. - * - * @param string $filename - * @param string $ref Reference name (e.g. branch or tag). - * @return StdClass|null - */ - protected function getLatestCommit($filename, $ref = 'master') { - $commits = $this->api( - '/repos/:user/:repo/commits', - array( - 'path' => $filename, - 'sha' => $ref, - ) - ); - if ( !is_wp_error($commits) && is_array($commits) && isset($commits[0]) ) { - return $commits[0]; - } - return null; - } - protected function getRemoteChangelog($ref = '') { $filename = $this->getChangelogFilename(); if ( empty($filename) ) { return null; } - $changelog = $this->getRemoteFile($filename, $ref); + $changelog = $this->api->getRemoteFile($filename, $ref); if ( $changelog === null ) { return null; } @@ -243,113 +172,15 @@ class Puc_v4_GitHub_PluginChecker extends Puc_v4_Plugin_UpdateChecker { return $instance->text($markdown); } - /** - * Perform a GitHub API request. - * - * @param string $url - * @param array $queryParams - * @return mixed|WP_Error - */ - protected function api($url, $queryParams = array()) { - $variables = array( - 'user' => $this->userName, - 'repo' => $this->repositoryName, - ); - foreach ($variables as $name => $value) { - $url = str_replace('/:' . $name, '/' . urlencode($value), $url); - } - $url = 'https://api.github.com' . $url; - - if ( !empty($this->accessToken) ) { - $queryParams['access_token'] = $this->accessToken; - } - if ( !empty($queryParams) ) { - $url = add_query_arg($queryParams, $url); - } - - $response = wp_remote_get($url, array('timeout' => 10)); - if ( is_wp_error($response) ) { - return $response; - } - - $code = wp_remote_retrieve_response_code($response); - $body = wp_remote_retrieve_body($response); - if ( $code === 200 ) { - $document = json_decode($body); - return $document; - } - - return new WP_Error( - 'puc-github-http-error', - 'GitHub API error. HTTP status: ' . $code - ); - } - /** * Set the access token that will be used to make authenticated GitHub API requests. * * @param string $accessToken + * @return $this */ public function setAccessToken($accessToken) { $this->accessToken = $accessToken; - } - - /** - * Get the contents of a file from a specific branch or tag. - * - * @param string $path File name. - * @param string $ref - * @return null|string Either the contents of the file, or null if the file doesn't exist or there's an error. - */ - protected function getRemoteFile($path, $ref = 'master') { - $apiUrl = '/repos/:user/:repo/contents/' . $path; - $response = $this->api($apiUrl, array('ref' => $ref)); - - if ( is_wp_error($response) || !isset($response->content) || ($response->encoding !== 'base64') ) { - return null; - } - return base64_decode($response->content); - } - - /** - * Parse plugin metadata from the header comment. - * This is basically a simplified version of the get_file_data() function from /wp-includes/functions.php. - * - * @param $content - * @return array - */ - protected function getFileHeader($content) { - $headers = array( - 'Name' => 'Plugin Name', - 'PluginURI' => 'Plugin URI', - 'Version' => 'Version', - 'Description' => 'Description', - 'Author' => 'Author', - 'AuthorURI' => 'Author URI', - 'TextDomain' => 'Text Domain', - 'DomainPath' => 'Domain Path', - 'Network' => 'Network', - - //The newest WordPress version that this plugin requires or has been tested with. - //We support several different formats for compatibility with other libraries. - 'Tested WP' => 'Tested WP', - 'Requires WP' => 'Requires WP', - 'Tested up to' => 'Tested up to', - 'Requires at least' => 'Requires at least', - ); - - $content = str_replace("\r", "\n", $content); //Normalize line endings. - $results = array(); - foreach ($headers as $field => $name) { - $success = preg_match('/^[ \t\/*#@]*' . preg_quote($name, '/') . ':(.*)$/mi', $content, $matches); - if ( ($success === 1) && $matches[1] ) { - $results[$field] = _cleanup_header_comment($matches[1]); - } else { - $results[$field] = ''; - } - } - - return $results; + return $this; } /** @@ -390,7 +221,7 @@ class Puc_v4_GitHub_PluginChecker extends Puc_v4_Plugin_UpdateChecker { * @param Puc_v4_Plugin_Info $pluginInfo */ protected function setInfoFromRemoteReadme($ref, $pluginInfo) { - $readmeTxt = $this->getRemoteFile('readme.txt', $ref); + $readmeTxt = $this->api->getRemoteFile('readme.txt', $ref); if ( empty($readmeTxt) ) { return; } @@ -429,25 +260,6 @@ class Puc_v4_GitHub_PluginChecker extends Puc_v4_Plugin_UpdateChecker { } return is_file($pluginDirectory . '/readme.txt'); } - - /** - * Generate a URL to download a ZIP archive of the specified branch/tag/etc. - * - * @param string $ref - * @return string - */ - protected function buildArchiveDownloadUrl($ref = 'master') { - $url = sprintf( - 'https://api.github.com/repos/%1$s/%2$s/zipball/%3$s', - urlencode($this->userName), - urlencode($this->repositoryName), - urlencode($ref) - ); - if ( !empty($this->accessToken) ) { - $url = add_query_arg('access_token', $this->accessToken, $url); - } - return $url; - } } endif; \ No newline at end of file diff --git a/Puc/v4/GitHub/ThemeUpdateChecker.php b/Puc/v4/GitHub/ThemeUpdateChecker.php new file mode 100644 index 0000000..3e9bcfe --- /dev/null +++ b/Puc/v4/GitHub/ThemeUpdateChecker.php @@ -0,0 +1,99 @@ +repositoryUrl = $repositoryUrl; + parent::__construct($repositoryUrl, $stylesheet, $customSlug, $checkPeriod, $optionName); + } + + public function requestUpdate() { + $api = new Puc_v4_GitHub_Api($this->repositoryUrl, $this->accessToken); + + $update = new Puc_v4_Theme_Update(); + $update->slug = $this->slug; + + //Figure out which reference (tag or branch) we'll use to get the latest version of the theme. + $ref = $this->branch; + if ( $this->branch === 'master' ) { + //Use the latest release. + $release = $api->getLatestRelease(); + if ( $release !== null ) { + $ref = $release->tag_name; + $update->version = ltrim($release->tag_name, 'v'); //Remove the "v" prefix from "v1.2.3". + $update->download_url = $release->zipball_url; + } else { + //Failing that, use the tag with the highest version number. + $tag = $api->getLatestTag(); + if ( $tag !== null ) { + $ref = $tag->name; + $update->version = $tag->name; + $update->download_url = $tag->zipball_url; + } + } + } + + if ( empty($update->download_url) ) { + $update->download_url = $api->buildArchiveDownloadUrl($ref); + } else if ( !empty($this->accessToken) ) { + $update->download_url = add_query_arg('access_token', $this->accessToken, $update->download_url); + } + + //Get headers from the main stylesheet in this branch/tag. Its "Version" header and other metadata + //are what the WordPress install will actually see after upgrading, so they take precedence over releases/tags. + $remoteStylesheet = $api->getRemoteFile('style.css', $ref); + if ( !empty($remoteStylesheet) ) { + $remoteHeader = $this->getFileHeader($remoteStylesheet); + if ( !empty($remoteHeader['Version']) ) { + $update->version = $remoteHeader['Version']; + } + if ( !empty($remoteHeader['ThemeURI']) ) { + $update->details_url = $remoteHeader['ThemeURI']; + } + } + + //The details URL defaults to the Theme URI header or the repository URL. + if ( empty($update->details_url) ) { + $update->details_url = $this->theme->get('ThemeURI'); + } + if ( empty($update->details_url) ) { + $update->details_url = $this->repositoryUrl; + } + + if ( empty($update->version) ) { + //It looks like we didn't find a valid update after all. + $update = null; + } + + $update = $this->filterUpdateResult($update); + return $update; + } + + /** + * Set the GitHub branch to use for updates. Defaults to 'master'. + * + * @param string $branch + * @return $this + */ + public function setBranch($branch) { + $this->branch = empty($branch) ? 'master' : $branch; + return $this; + } + + /** + * Set the access token that will be used to make authenticated GitHub API requests. + * + * @param string $accessToken + * @return $this + */ + public function setAccessToken($accessToken) { + $this->accessToken = $accessToken; + return $this; + } + } + +endif; \ No newline at end of file diff --git a/Puc/v4/Plugin/UpdateChecker.php b/Puc/v4/Plugin/UpdateChecker.php index 11376f7..3c085a7 100644 --- a/Puc/v4/Plugin/UpdateChecker.php +++ b/Puc/v4/Plugin/UpdateChecker.php @@ -222,6 +222,30 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): return get_plugin_data($this->pluginAbsolutePath, false, false); } + /** + * @return array + */ + protected function getHeaderNames() { + return array( + 'Name' => 'Plugin Name', + 'PluginURI' => 'Plugin URI', + 'Version' => 'Version', + 'Description' => 'Description', + 'Author' => 'Author', + 'AuthorURI' => 'Author URI', + 'TextDomain' => 'Text Domain', + 'DomainPath' => 'Domain Path', + 'Network' => 'Network', + + //The newest WordPress version that this plugin requires or has been tested with. + //We support several different formats for compatibility with other libraries. + 'Tested WP' => 'Tested WP', + 'Requires WP' => 'Requires WP', + 'Tested up to' => 'Tested up to', + 'Requires at least' => 'Requires at least', + ); + } + /** * Intercept plugins_api() calls that request information about our plugin and diff --git a/Puc/v4/Theme/UpdateChecker.php b/Puc/v4/Theme/UpdateChecker.php index cc17612..a3c1ccf 100644 --- a/Puc/v4/Theme/UpdateChecker.php +++ b/Puc/v4/Theme/UpdateChecker.php @@ -180,6 +180,25 @@ if ( !class_exists('Puc_v4_Theme_UpdateChecker', false) ): public function addResultFilter($callback) { $this->addFilter('request_update_result', $callback, 10, 2); } + + /** + * @return array + */ + protected function getHeaderNames() { + return array( + 'Name' => 'Theme Name', + 'ThemeURI' => 'Theme URI', + 'Description' => 'Description', + 'Author' => 'Author', + 'AuthorURI' => 'Author URI', + 'Version' => 'Version', + 'Template' => 'Template', + 'Status' => 'Status', + 'Tags' => 'Tags', + 'TextDomain' => 'Text Domain', + 'DomainPath' => 'Domain Path', + ); + } } endif; \ No newline at end of file diff --git a/Puc/v4/UpdateChecker.php b/Puc/v4/UpdateChecker.php index efe7aa9..c009737 100644 --- a/Puc/v4/UpdateChecker.php +++ b/Puc/v4/UpdateChecker.php @@ -706,6 +706,50 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): return false; } + /* ------------------------------------------------------------------- + * File header parsing + * ------------------------------------------------------------------- + */ + + /** + * Parse plugin or theme metadata from the header comment. + * + * This is basically a simplified version of the get_file_data() function from /wp-includes/functions.php. + * It's intended as a utility for subclasses that detect updates by parsing files in a VCS. + * + * @param string $content File contents. + * @return string[] + */ + public function getFileHeader($content) { + //WordPress only looks at the first 8 KiB of the file, so we do the same. + $content = substr($content, 0, 8192); + //Normalize line endings. + $content = str_replace("\r", "\n", $content); + + $headers = $this->getHeaderNames(); + $results = array(); + foreach ($headers as $field => $name) { + $success = preg_match('/^[ \t\/*#@]*' . preg_quote($name, '/') . ':(.*)$/mi', $content, $matches); + + if ( ($success === 1) && $matches[1] ) { + $value = $matches[1]; + if ( function_exists('_cleanup_header_comment') ) { + $value = _cleanup_header_comment($value); + } + $results[$field] = $value; + } else { + $results[$field] = ''; + } + } + + return $results; + } + + /** + * @return array Format: ['HeaderKey' => 'Header Name'] + */ + abstract protected function getHeaderNames(); + /* ------------------------------------------------------------------- * DebugBar integration * ------------------------------------------------------------------- From 55077f4a03e0fb586a84e42697368237ec69bcbd Mon Sep 17 00:00:00 2001 From: Yahnis Elsts Date: Tue, 20 Dec 2016 17:06:14 +0200 Subject: [PATCH 11/44] WIP: BitBucket support. Not usable yet! --- Puc/v4/BitBucket/Api.php | 89 ++++++++++++ Puc/v4/BitBucket/PluginUpdateChecker.php | 175 +++++++++++++++++++++++ Puc/v4/Factory.php | 2 +- plugin-update-checker.php | 2 + 4 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 Puc/v4/BitBucket/Api.php create mode 100644 Puc/v4/BitBucket/PluginUpdateChecker.php diff --git a/Puc/v4/BitBucket/Api.php b/Puc/v4/BitBucket/Api.php new file mode 100644 index 0000000..8978571 --- /dev/null +++ b/Puc/v4/BitBucket/Api.php @@ -0,0 +1,89 @@ +findChangelogName($localDirectory); + if ( empty($filename) ) { + return null; + } + + $changelog = $this->getRemoteFile($filename, $ref); + if ( $changelog === null ) { + return null; + } + + /** @noinspection PhpUndefinedClassInspection */ + $instance = Parsedown::instance(); + return $instance->text($changelog); + } + + protected function findChangelogName($directory) { + if ( empty($directory) || !is_dir($directory) || ($directory === '.') ) { + return null; + } + + $possibleNames = array('CHANGES.md', 'CHANGELOG.md', 'changes.md', 'changelog.md'); + $files = scandir($directory); + $foundNames = array_intersect($possibleNames, $files); + + if ( !empty($foundNames) ) { + return reset($foundNames); + } + return null; + } + } + +endif; \ No newline at end of file diff --git a/Puc/v4/BitBucket/PluginUpdateChecker.php b/Puc/v4/BitBucket/PluginUpdateChecker.php new file mode 100644 index 0000000..112f63e --- /dev/null +++ b/Puc/v4/BitBucket/PluginUpdateChecker.php @@ -0,0 +1,175 @@ +api = new Puc_v4_BitBucket_Api( + $this->repositoryUrl, + array() + ); + + $info = new Puc_v4_Plugin_Info(); + $info->filename = $this->pluginFile; + $info->slug = $this->slug; + + $this->setInfoFromHeader($this->getPluginHeader(), $info); + + //Figure out which reference (tag or branch) we'll use to get the latest version of the plugin. + $ref = $this->branch; + $foundVersion = false; + + //Check if there's a "Stable tag: v1.2.3" header that points to a valid tag. + $remoteReadme = $api->getRemoteReadme($this->branch); + if ( !empty($remoteReadme['stable_tag']) ) { + $tag = $api->getTag($remoteReadme['stable_tag']); + if ( ($tag !== null) && isset($tag->name) ) { + $ref = $tag->name; + $info->version = ltrim($tag->name, 'v'); + $info->last_updated = $tag->target->date; + //TODO: Download url + $foundVersion = true; + } + } + + //Look for version-like tags. + if ( ($this->branch === 'master') && !$foundVersion ) { + $tag = $api->getLatestTag(); + if ( ($tag !== null) && isset($tag->name) ) { + $ref = $tag->name; + $info->version = ltrim($tag->name, 'v'); + $info->last_updated = $tag->target->date; + //TODO: Download url + $foundVersion = true; + } + } + + //If all else fails, use the specified branch itself. + if ( !$foundVersion ) { + $ref = $this->branch; + //TODO: Download url for this branch. + } + + //Get headers from the main plugin file in this branch/tag. Its "Version" header and other metadata + //are what the WordPress install will actually see after upgrading, so they take precedence over releases/tags. + $mainPluginFile = basename($this->pluginFile); + $remotePlugin = $api->getRemoteFile($mainPluginFile, $ref); + if ( !empty($remotePlugin) ) { + $remoteHeader = $this->getFileHeader($remotePlugin); + $this->setInfoFromHeader($remoteHeader, $info); + } + + //Try parsing readme.txt. If it's formatted according to WordPress.org standards, it will contain + //a lot of useful information like the required/tested WP version, changelog, and so on. + if ( $this->readmeTxtExistsLocally() ) { + $this->setInfoFromRemoteReadme($ref, $info); + } + + //The changelog might be in a separate file. + if ( empty($info->sections['changelog']) ) { + $info->sections['changelog'] = $api->getRemoteChangelog($ref, dirname($this->getAbsolutePath())); + if ( empty($info->sections['changelog']) ) { + $info->sections['changelog'] = __('There is no changelog available.', 'plugin-update-checker'); + } + } + + if ( empty($info->last_updated) ) { + //Fetch the latest commit that changed the tag or branch and use it as the "last_updated" date. + $latestCommitTime = $api->getLatestCommitTime($ref); + if ( $latestCommitTime !== null ) { + $info->last_updated = $latestCommitTime; + } + } + + $info = apply_filters($this->getUniqueName('request_info_result'), $info, null); + return $info; + } + + /** + * Check if the currently installed version has a readme.txt file. + * + * @return bool + */ + protected function readmeTxtExistsLocally() { + $pluginDirectory = dirname($this->pluginAbsolutePath); + if ( empty($this->pluginAbsolutePath) || !is_dir($pluginDirectory) || ($pluginDirectory === '.') ) { + return false; + } + return is_file($pluginDirectory . '/readme.txt'); + } + + /** + * Copy plugin metadata from a file header to a Plugin Info object. + * + * @param array $fileHeader + * @param Puc_v4_Plugin_Info $pluginInfo + */ + protected function setInfoFromHeader($fileHeader, $pluginInfo) { + $headerToPropertyMap = array( + 'Version' => 'version', + 'Name' => 'name', + 'PluginURI' => 'homepage', + 'Author' => 'author', + 'AuthorName' => 'author', + 'AuthorURI' => 'author_homepage', + + 'Requires WP' => 'requires', + 'Tested WP' => 'tested', + 'Requires at least' => 'requires', + 'Tested up to' => 'tested', + ); + foreach ($headerToPropertyMap as $headerName => $property) { + if ( isset($fileHeader[$headerName]) && !empty($fileHeader[$headerName]) ) { + $pluginInfo->$property = $fileHeader[$headerName]; + } + } + + if ( !empty($fileHeader['Description']) ) { + $pluginInfo->sections['description'] = $fileHeader['Description']; + } + } + + /** + * Copy plugin metadata from the remote readme.txt file. + * + * @param string $ref GitHub tag or branch where to look for the readme. + * @param Puc_v4_Plugin_Info $pluginInfo + */ + protected function setInfoFromRemoteReadme($ref, $pluginInfo) { + $readme = $this->api->getRemoteReadme($ref); + if ( empty($readme) ) { + return; + } + + if ( isset($readme['sections']) ) { + $pluginInfo->sections = array_merge($pluginInfo->sections, $readme['sections']); + } + if ( !empty($readme['tested_up_to']) ) { + $pluginInfo->tested = $readme['tested_up_to']; + } + if ( !empty($readme['requires_at_least']) ) { + $pluginInfo->requires = $readme['requires_at_least']; + } + + if ( isset($readme['upgrade_notice'], $readme['upgrade_notice'][$pluginInfo->version]) ) { + $pluginInfo->upgrade_notice = $readme['upgrade_notice'][$pluginInfo->version]; + } + } + } + +endif; \ No newline at end of file diff --git a/Puc/v4/Factory.php b/Puc/v4/Factory.php index 23d13e7..52be8ff 100644 --- a/Puc/v4/Factory.php +++ b/Puc/v4/Factory.php @@ -61,7 +61,7 @@ if ( !class_exists('Puc_v4_Factory', false) ): case 'github.com': $service = 'GitHub'; break; - case 'bitbucket.com': + case 'bitbucket.org': $service = 'BitBucket'; break; case 'gitlab.com': diff --git a/plugin-update-checker.php b/plugin-update-checker.php index beb515d..81dbd87 100644 --- a/plugin-update-checker.php +++ b/plugin-update-checker.php @@ -15,3 +15,5 @@ Puc_v4_Factory::addVersion('Plugin_UpdateChecker', 'Puc_v4_Plugin_UpdateChecker' Puc_v4_Factory::addVersion('Theme_UpdateChecker', 'Puc_v4_Theme_UpdateChecker', '4.0'); Puc_v4_Factory::addVersion('GitHub_PluginUpdateChecker', 'Puc_v4_GitHub_PluginUpdateChecker', '4.0'); Puc_v4_Factory::addVersion('GitHub_ThemeUpdateChecker', 'Puc_v4_GitHub_ThemeUpdateChecker', '4.0'); + +Puc_v4_Factory::addVersion('BitBucket_PluginUpdateChecker', 'Puc_v4_BitBucket_PluginUpdateChecker', '4.0'); From 5b427b4e283fca389feb5fe15f5d86094e38c980 Mon Sep 17 00:00:00 2001 From: Yahnis Elsts Date: Thu, 22 Dec 2016 16:05:15 +0200 Subject: [PATCH 12/44] Minor: Fix typo in a comment. --- vendor/readme-parser.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/readme-parser.php b/vendor/readme-parser.php index ed21eab..a40b4b3 100644 --- a/vendor/readme-parser.php +++ b/vendor/readme-parser.php @@ -8,7 +8,7 @@ Class PucReadmeParser { function __construct() { - // This space intentially blank + // This space intentionally blank } function parse_readme( $file ) { From 3692f5201ea11a064fd85bf3532c474c7a79eda3 Mon Sep 17 00:00:00 2001 From: Yahnis Elsts Date: Thu, 22 Dec 2016 18:49:51 +0200 Subject: [PATCH 13/44] Fix autoloading of files in the "vendor" subdirectory. I was using the wrong directory path, oops. --- Puc/v4/Autoloader.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Puc/v4/Autoloader.php b/Puc/v4/Autoloader.php index 2067531..8376537 100644 --- a/Puc/v4/Autoloader.php +++ b/Puc/v4/Autoloader.php @@ -3,6 +3,7 @@ class Puc_v4_Autoloader { private $prefix = ''; private $rootDir = ''; + private $libraryDir = ''; private $staticMap; @@ -11,6 +12,7 @@ class Puc_v4_Autoloader { $nameParts = explode('_', __CLASS__, 3); $this->prefix = $nameParts[0] . '_' . $nameParts[1] . '_'; + $this->libraryDir = realpath($this->rootDir . '../..') . '/'; $this->staticMap = array( 'PucReadmeParser' => 'vendor/readme-parser.php', 'Parsedown' => 'vendor/ParsedownLegacy.php', @@ -23,9 +25,9 @@ class Puc_v4_Autoloader { } public function autoload($className) { - if ( isset($this->staticMap[$className]) && file_exists($this->rootDir . $this->staticMap[$className]) ) { + if ( isset($this->staticMap[$className]) && file_exists($this->libraryDir . $this->staticMap[$className]) ) { /** @noinspection PhpIncludeInspection */ - include ($this->rootDir . $this->staticMap[$className]); + include ($this->libraryDir . $this->staticMap[$className]); return; } From f64c3170cc6da6119eefdc55813a076d5cfd3da5 Mon Sep 17 00:00:00 2001 From: Yahnis Elsts Date: Thu, 22 Dec 2016 19:10:05 +0200 Subject: [PATCH 14/44] BitBucket support is now semi-usable. More testing required. Could probably refactor to reduce duplication; lots of overlap with GitHub integration. --- Puc/v4/BitBucket/Api.php | 152 ++++++++++++++++++++++- Puc/v4/BitBucket/PluginUpdateChecker.php | 52 +++++--- Puc/v4/Factory.php | 2 +- Puc/v4/OAuthSignature.php | 87 +++++++++++++ vendor/readme-parser.php | 6 +- 5 files changed, 276 insertions(+), 23 deletions(-) create mode 100644 Puc/v4/OAuthSignature.php diff --git a/Puc/v4/BitBucket/Api.php b/Puc/v4/BitBucket/Api.php index 8978571..89c86ba 100644 --- a/Puc/v4/BitBucket/Api.php +++ b/Puc/v4/BitBucket/Api.php @@ -2,16 +2,50 @@ if ( !class_exists('Puc_v4_BitBucket_Api', false) ): class Puc_v4_BitBucket_Api { - public function __construct($repositoryUrl, $credentials = array()) { + /** + * @var Puc_v4_OAuthSignature + */ + private $oauth = null; + /** + * @var string + */ + private $username; + + /** + * @var string + */ + private $repository; + + public function __construct($repositoryUrl, $credentials = array()) { + $path = @parse_url($repositoryUrl, PHP_URL_PATH); + if ( preg_match('@^/?(?P[^/]+?)/(?P[^/#?&]+?)/?$@', $path, $matches) ) { + $this->username = $matches['username']; + $this->repository = $matches['repository']; + } else { + throw new InvalidArgumentException('Invalid BitBucket repository URL: "' . $repositoryUrl . '"'); + } + + if ( !empty($credentials) && !empty($credentials['consumer_key']) ) { + $this->oauth = new Puc_v4_OAuthSignature( + $credentials['consumer_key'], + $credentials['consumer_secret'] + ); + } } /** * @param string $ref * @return array */ - public function getRemoteReadme($ref) { - return array(); + public function getRemoteReadme($ref = 'master') { + $fileContents = $this->getRemoteFile('readme.txt', $ref); + if ( empty($fileContents) ) { + return array(); + } + + $parser = new PucReadmeParser(); + return $parser->parse_readme_contents($fileContents); } /** @@ -21,7 +55,11 @@ if ( !class_exists('Puc_v4_BitBucket_Api', false) ): * @return stdClass|null */ public function getTag($tagName) { - return null; + $tag = $this->api('/refs/tags/' . $tagName); + if ( is_wp_error($tag) || empty($tag) ) { + return null; + } + return $tag; } /** @@ -30,9 +68,63 @@ if ( !class_exists('Puc_v4_BitBucket_Api', false) ): * @return stdClass|null */ public function getLatestTag() { + $tags = $this->api('/refs/tags'); + if ( !isset($tags, $tags->values) || !is_array($tags->values) ) { + return null; + } + + //Keep only those tags that look like version numbers. + $versionTags = array_filter($tags->values, array($this, 'isVersionTag')); + //Sort them in descending order. + usort($versionTags, array($this, 'compareTagNames')); + + //Return the first result. + if ( !empty($versionTags) ) { + return $versionTags[0]; + } return null; } + protected function isVersionTag($tag) { + return isset($tag->name) && $this->looksLikeVersion($tag->name); + } + + /** + * Check if a tag name string looks like a version number. + * + * @param string $name + * @return bool + */ + protected function looksLikeVersion($name) { + //Tag names may be prefixed with "v", e.g. "v1.2.3". + $name = ltrim($name, 'v'); + + //The version string must start with a number. + if ( !is_numeric($name) ) { + return false; + } + + //The goal is to accept any SemVer-compatible or "PHP-standardized" version number. + return (preg_match('@^(\d{1,5}?)(\.\d{1,10}?){0,4}?($|[abrdp+_\-]|\s)@i', $name) === 1); + } + + /** + * Compare two BitBucket tags as if they were version number. + * + * @param string $tag1 + * @param string $tag2 + * @return int + */ + protected function compareTagNames($tag1, $tag2) { + if ( !isset($tag1->name) ) { + return 1; + } + if ( !isset($tag2->name) ) { + return -1; + } + return -version_compare(ltrim($tag1->name, 'v'), ltrim($tag2->name, 'v')); + } + /** * Get the contents of a file from a specific branch or tag. * @@ -41,7 +133,11 @@ if ( !class_exists('Puc_v4_BitBucket_Api', false) ): * @return null|string Either the contents of the file, or null if the file doesn't exist or there's an error. */ public function getRemoteFile($path, $ref = 'master') { - return null; + $response = $this->api('src/' . $ref . '/' . ltrim($path), '1.0'); + if ( is_wp_error($response) || !isset($response, $response->data) ) { + return null; + } + return $response->data; } /** @@ -51,6 +147,10 @@ if ( !class_exists('Puc_v4_BitBucket_Api', false) ): * @return string|null */ public function getLatestCommitTime($ref) { + $response = $this->api('commits/' . $ref); + if ( isset($response->values, $response->values[0], $response->values[0]->date) ) { + return $response->values[0]->date; + } return null; } @@ -84,6 +184,48 @@ if ( !class_exists('Puc_v4_BitBucket_Api', false) ): } return null; } + + /** + * Perform a BitBucket API 2.0 request. + * + * @param string $url + * @param string $version + * @return mixed|WP_Error + */ + public function api($url, $version = '2.0') { + //printf('Requesting %s
' . "\n", $url); + + $url = implode('/', array( + 'https://api.bitbucket.org', + $version, + 'repositories', + $this->username, + $this->repository, + ltrim($url, '/') + )); + + if ( $this->oauth ) { + $url = $this->oauth->sign($url,'GET'); + } + + $response = wp_remote_get($url, array('timeout' => 10)); + //var_dump($response); + if ( is_wp_error($response) ) { + return $response; + } + + $code = wp_remote_retrieve_response_code($response); + $body = wp_remote_retrieve_body($response); + if ( $code === 200 ) { + $document = json_decode($body); + return $document; + } + + return new WP_Error( + 'puc-bitbucket-http-error', + 'BitBucket API error. HTTP status: ' . $code + ); + } } endif; \ No newline at end of file diff --git a/Puc/v4/BitBucket/PluginUpdateChecker.php b/Puc/v4/BitBucket/PluginUpdateChecker.php index 112f63e..927bf0a 100644 --- a/Puc/v4/BitBucket/PluginUpdateChecker.php +++ b/Puc/v4/BitBucket/PluginUpdateChecker.php @@ -5,24 +5,17 @@ if ( !class_exists('Puc_v4_BitBucket_PluginUpdateChecker') ): /** * @var string */ - protected $repositoryUrl; - - /** - * @var string - */ - protected $branch; + protected $branch = 'master'; /** * @var Puc_v4_BitBucket_Api */ protected $api; + protected $credentials = array(); + public function requestInfo($queryArgs = array()) { - //TODO: BitBucket support - $api = $this->api = new Puc_v4_BitBucket_Api( - $this->repositoryUrl, - array() - ); + $api = $this->api = new Puc_v4_BitBucket_Api($this->metadataUrl, $this->credentials); $info = new Puc_v4_Plugin_Info(); $info->filename = $this->pluginFile; @@ -42,7 +35,6 @@ if ( !class_exists('Puc_v4_BitBucket_PluginUpdateChecker') ): $ref = $tag->name; $info->version = ltrim($tag->name, 'v'); $info->last_updated = $tag->target->date; - //TODO: Download url $foundVersion = true; } } @@ -54,7 +46,6 @@ if ( !class_exists('Puc_v4_BitBucket_PluginUpdateChecker') ): $ref = $tag->name; $info->version = ltrim($tag->name, 'v'); $info->last_updated = $tag->target->date; - //TODO: Download url $foundVersion = true; } } @@ -62,9 +53,10 @@ if ( !class_exists('Puc_v4_BitBucket_PluginUpdateChecker') ): //If all else fails, use the specified branch itself. if ( !$foundVersion ) { $ref = $this->branch; - //TODO: Download url for this branch. } + $info->download_url = trailingslashit($this->metadataUrl) . 'get/' . $ref . '.zip'; + //Get headers from the main plugin file in this branch/tag. Its "Version" header and other metadata //are what the WordPress install will actually see after upgrading, so they take precedence over releases/tags. $mainPluginFile = basename($this->pluginFile); @@ -76,7 +68,7 @@ if ( !class_exists('Puc_v4_BitBucket_PluginUpdateChecker') ): //Try parsing readme.txt. If it's formatted according to WordPress.org standards, it will contain //a lot of useful information like the required/tested WP version, changelog, and so on. - if ( $this->readmeTxtExistsLocally() ) { + if ( $this->readmeTxtExistsLocally() || !empty($remoteReadme) ) { $this->setInfoFromRemoteReadme($ref, $info); } @@ -100,6 +92,16 @@ if ( !class_exists('Puc_v4_BitBucket_PluginUpdateChecker') ): return $info; } + public function setAuthentication($credentials) { + $this->credentials = array_merge( + array( + 'consumer_key' => '', + 'consumer_secret' => '', + ), + $credentials + ); + } + /** * Check if the currently installed version has a readme.txt file. * @@ -170,6 +172,26 @@ if ( !class_exists('Puc_v4_BitBucket_PluginUpdateChecker') ): $pluginInfo->upgrade_notice = $readme['upgrade_notice'][$pluginInfo->version]; } } + + public function getUpdate() { + $update = parent::getUpdate(); + + //Add authentication data to download URLs. Since OAuth signatures incorporate + //timestamps, we have to do this immediately before inserting the update. Otherwise + //authentication could fail due to a stale timestamp. + if ( isset($update, $update->download_url) && !empty($update->download_url) && !empty($this->credentials) ) { + if ( !empty($this->credentials['consumer_key']) ) { + $oauth = new Puc_v4_OAuthSignature( + $this->credentials['consumer_key'], + $this->credentials['consumer_secret'] + ); + $update->download_url = $oauth->sign($update->download_url); + } + } + + return $update; + } + } endif; \ No newline at end of file diff --git a/Puc/v4/Factory.php b/Puc/v4/Factory.php index 52be8ff..3823829 100644 --- a/Puc/v4/Factory.php +++ b/Puc/v4/Factory.php @@ -31,7 +31,7 @@ if ( !class_exists('Puc_v4_Factory', false) ): * @param int $checkPeriod How often to check for updates (in hours). * @param string $optionName Where to store book-keeping info about update checks. * @param string $muPluginFile The plugin filename relative to the mu-plugins directory. - * @return Puc_v4_UpdateChecker + * @return Puc_v4_Plugin_UpdateChecker|Puc_v4_Theme_UpdateChecker */ public static function buildUpdateChecker($metadataUrl, $fullPath, $slug = '', $checkPeriod = 12, $optionName = '', $muPluginFile = '') { $fullPath = wp_normalize_path($fullPath); diff --git a/Puc/v4/OAuthSignature.php b/Puc/v4/OAuthSignature.php new file mode 100644 index 0000000..f9242c4 --- /dev/null +++ b/Puc/v4/OAuthSignature.php @@ -0,0 +1,87 @@ +consumerKey = $consumerKey; + $this->consumerSecret = $consumerSecret; + } + + /** + * Sign a URL using OAuth 1.0. + * + * @param string $url The URL to be signed. It may contain query parameters. + * @param string $method HTTP method such as "GET", "POST" and so on. + * @return string The signed URL. + */ + public function sign($url, $method = 'GET') { + $parameters = array(); + + //Parse query parameters. + $query = @parse_url($url, PHP_URL_QUERY); + if ( !empty($query) ) { + parse_str($query, $parsedParams); + if ( is_array($parameters) ) { + $parameters = $parsedParams; + } + //Remove the query string from the URL. We'll replace it later. + $url = substr($url, 0, strpos($url, '?')); + } + + $parameters = array_merge( + $parameters, + array( + 'oauth_consumer_key' => $this->consumerKey, + 'oauth_nonce' => $this->nonce(), + 'oauth_signature_method' => 'HMAC-SHA1', + 'oauth_timestamp' => time(), + 'oauth_version' => '1.0', + ) + ); + + //Parameters must be sorted alphabetically before signing. + ksort($parameters); + + //The most complicated part of the request - generating the signature. + //The string to sign contains the HTTP method, the URL path, and all of + //our query parameters. Everything is URL encoded. Then we concatenate + //them with ampersands into a single string to hash. + $encodedVerb = urlencode($method); + $encodedUrl = urlencode($url); + $encodedParams = urlencode(http_build_query($parameters, '', '&')); + + $stringToSign = $encodedVerb . '&' . $encodedUrl . '&' . $encodedParams; + + //Since we only have one OAuth token (the consumer secret) we only have + //to use it as our HMAC key. However, we still have to append an & to it + //as if we were using it with additional tokens. + $secret = urlencode($this->consumerSecret) . '&'; + + //The signature is a hash of the consumer key and the base string. Note + //that we have to get the raw output from hash_hmac and base64 encode + //the binary data result. + $parameters['oauth_signature'] = base64_encode(hash_hmac('sha1', $stringToSign, $secret, true)); + + return ($url . '?' . http_build_query($parameters)); + } + + /** + * Generate a random nonce. + * + * @return string + */ + private function nonce() { + $mt = microtime(); + $rand = mt_rand(); + return md5($mt . '_' . $rand); + } + } + +endif; \ No newline at end of file diff --git a/vendor/readme-parser.php b/vendor/readme-parser.php index a40b4b3..334bca7 100644 --- a/vendor/readme-parser.php +++ b/vendor/readme-parser.php @@ -1,11 +1,13 @@ Date: Sat, 24 Dec 2016 15:04:25 +0200 Subject: [PATCH 15/44] Refactor BitBucket and GitHub checkers to be more similar. In the future, it would probably be possible to add a general base class for repository-based updates. --- Puc/v4/BitBucket/Api.php | 110 +++++++----------- Puc/v4/BitBucket/PluginUpdateChecker.php | 88 ++++++++------ Puc/v4/GitHub/Api.php | 81 ++++++++++++- Puc/v4/GitHub/PluginUpdateChecker.php | 115 ++++++------------ Puc/v4/GitHub/ThemeUpdateChecker.php | 10 +- Puc/v4/VcsApi.php | 142 +++++++++++++++++++++++ Puc/v4/VcsReference.php | 49 ++++++++ 7 files changed, 407 insertions(+), 188 deletions(-) create mode 100644 Puc/v4/VcsApi.php create mode 100644 Puc/v4/VcsReference.php diff --git a/Puc/v4/BitBucket/Api.php b/Puc/v4/BitBucket/Api.php index 89c86ba..591f8ff 100644 --- a/Puc/v4/BitBucket/Api.php +++ b/Puc/v4/BitBucket/Api.php @@ -1,7 +1,7 @@ repositoryUrl = $repositoryUrl; + $path = @parse_url($repositoryUrl, PHP_URL_PATH); if ( preg_match('@^/?(?P[^/]+?)/(?P[^/#?&]+?)/?$@', $path, $matches) ) { $this->username = $matches['username']; @@ -34,38 +41,43 @@ if ( !class_exists('Puc_v4_BitBucket_Api', false) ): } } - /** - * @param string $ref - * @return array - */ - public function getRemoteReadme($ref = 'master') { - $fileContents = $this->getRemoteFile('readme.txt', $ref); - if ( empty($fileContents) ) { - return array(); + public function getBranch($branchName) { + $branch = $this->api('/refs/branches/' . $branchName); + if ( is_wp_error($branch) || empty($branch) ) { + return null; } - $parser = new PucReadmeParser(); - return $parser->parse_readme_contents($fileContents); + return new Puc_v4_VcsReference(array( + 'name' => $branch->name, + 'updated' => $branch->target->date, + 'downloadUrl' => $this->getDownloadUrl($branch->name), + )); } /** * Get a specific tag. * * @param string $tagName - * @return stdClass|null + * @return Puc_v4_VcsReference|null */ public function getTag($tagName) { $tag = $this->api('/refs/tags/' . $tagName); if ( is_wp_error($tag) || empty($tag) ) { return null; } - return $tag; + + return new Puc_v4_VcsReference(array( + 'name' => $tag->name, + 'version' => ltrim($tag->name, 'v'), + 'updated' => $tag->target->date, + 'downloadUrl' => $this->getDownloadUrl($tag->name), + )); } /** * Get the tag that looks like the highest version number. * - * @return stdClass|null + * @return Puc_v4_VcsReference|null */ public function getLatestTag() { $tags = $this->api('/refs/tags'); @@ -80,39 +92,34 @@ if ( !class_exists('Puc_v4_BitBucket_Api', false) ): //Return the first result. if ( !empty($versionTags) ) { - return $versionTags[0]; + $tag = $versionTags[0]; + return new Puc_v4_VcsReference(array( + 'name' => $tag->name, + 'version' => ltrim($tag->name, 'v'), + 'updated' => $tag->target->date, + 'downloadUrl' => $this->getDownloadUrl($tag->name), + )); } return null; } + /** + * @param string $ref + * @return string + */ + protected function getDownloadUrl($ref) { + return trailingslashit($this->repositoryUrl) . 'get/' . $ref . '.zip'; + } + protected function isVersionTag($tag) { return isset($tag->name) && $this->looksLikeVersion($tag->name); } - /** - * Check if a tag name string looks like a version number. - * - * @param string $name - * @return bool - */ - protected function looksLikeVersion($name) { - //Tag names may be prefixed with "v", e.g. "v1.2.3". - $name = ltrim($name, 'v'); - - //The version string must start with a number. - if ( !is_numeric($name) ) { - return false; - } - - //The goal is to accept any SemVer-compatible or "PHP-standardized" version number. - return (preg_match('@^(\d{1,5}?)(\.\d{1,10}?){0,4}?($|[abrdp+_\-]|\s)@i', $name) === 1); - } - /** * Compare two BitBucket tags as if they were version number. * - * @param string $tag1 - * @param string $tag2 + * @param stdClass $tag1 + * @param stdClass $tag2 * @return int */ protected function compareTagNames($tag1, $tag2) { @@ -154,37 +161,6 @@ if ( !class_exists('Puc_v4_BitBucket_Api', false) ): return null; } - public function getRemoteChangelog($ref, $localDirectory) { - $filename = $this->findChangelogName($localDirectory); - if ( empty($filename) ) { - return null; - } - - $changelog = $this->getRemoteFile($filename, $ref); - if ( $changelog === null ) { - return null; - } - - /** @noinspection PhpUndefinedClassInspection */ - $instance = Parsedown::instance(); - return $instance->text($changelog); - } - - protected function findChangelogName($directory) { - if ( empty($directory) || !is_dir($directory) || ($directory === '.') ) { - return null; - } - - $possibleNames = array('CHANGES.md', 'CHANGELOG.md', 'changes.md', 'changelog.md'); - $files = scandir($directory); - $foundNames = array_intersect($possibleNames, $files); - - if ( !empty($foundNames) ) { - return reset($foundNames); - } - return null; - } - /** * Perform a BitBucket API 2.0 request. * diff --git a/Puc/v4/BitBucket/PluginUpdateChecker.php b/Puc/v4/BitBucket/PluginUpdateChecker.php index 927bf0a..a3dae8c 100644 --- a/Puc/v4/BitBucket/PluginUpdateChecker.php +++ b/Puc/v4/BitBucket/PluginUpdateChecker.php @@ -15,6 +15,10 @@ if ( !class_exists('Puc_v4_BitBucket_PluginUpdateChecker') ): protected $credentials = array(); public function requestInfo($queryArgs = array()) { + //We have to make several remote API requests to gather all the necessary info + //which can take a while on slow networks. + set_time_limit(60); + $api = $this->api = new Puc_v4_BitBucket_Api($this->metadataUrl, $this->credentials); $info = new Puc_v4_Plugin_Info(); @@ -23,40 +27,18 @@ if ( !class_exists('Puc_v4_BitBucket_PluginUpdateChecker') ): $this->setInfoFromHeader($this->getPluginHeader(), $info); - //Figure out which reference (tag or branch) we'll use to get the latest version of the plugin. - $ref = $this->branch; - $foundVersion = false; - - //Check if there's a "Stable tag: v1.2.3" header that points to a valid tag. - $remoteReadme = $api->getRemoteReadme($this->branch); - if ( !empty($remoteReadme['stable_tag']) ) { - $tag = $api->getTag($remoteReadme['stable_tag']); - if ( ($tag !== null) && isset($tag->name) ) { - $ref = $tag->name; - $info->version = ltrim($tag->name, 'v'); - $info->last_updated = $tag->target->date; - $foundVersion = true; - } + //Pick a branch or tag. + $updateSource = $this->chooseReference(); + if ( $updateSource ) { + $ref = $updateSource->name; + $info->version = $updateSource->version; + $info->last_updated = $updateSource->updated; + $info->download_url = $updateSource->downloadUrl; + } else { + //There's probably a network problem or an authentication error. + return null; } - //Look for version-like tags. - if ( ($this->branch === 'master') && !$foundVersion ) { - $tag = $api->getLatestTag(); - if ( ($tag !== null) && isset($tag->name) ) { - $ref = $tag->name; - $info->version = ltrim($tag->name, 'v'); - $info->last_updated = $tag->target->date; - $foundVersion = true; - } - } - - //If all else fails, use the specified branch itself. - if ( !$foundVersion ) { - $ref = $this->branch; - } - - $info->download_url = trailingslashit($this->metadataUrl) . 'get/' . $ref . '.zip'; - //Get headers from the main plugin file in this branch/tag. Its "Version" header and other metadata //are what the WordPress install will actually see after upgrading, so they take precedence over releases/tags. $mainPluginFile = basename($this->pluginFile); @@ -68,7 +50,7 @@ if ( !class_exists('Puc_v4_BitBucket_PluginUpdateChecker') ): //Try parsing readme.txt. If it's formatted according to WordPress.org standards, it will contain //a lot of useful information like the required/tested WP version, changelog, and so on. - if ( $this->readmeTxtExistsLocally() || !empty($remoteReadme) ) { + if ( $this->readmeTxtExistsLocally() ) { $this->setInfoFromRemoteReadme($ref, $info); } @@ -92,6 +74,40 @@ if ( !class_exists('Puc_v4_BitBucket_PluginUpdateChecker') ): return $info; } + /** + * Figure out which reference (tag or branch) we'll use to get the latest version of the plugin. + * + * @return Puc_v4_VcsReference|null + */ + protected function chooseReference() { + $api = $this->api; + $updateSource = null; + + //Check if there's a "Stable tag: v1.2.3" header that points to a valid tag. + $remoteReadme = $api->getRemoteReadme($this->branch); + if ( !empty($remoteReadme['stable_tag']) ) { + $tag = $remoteReadme['stable_tag']; + + //You can explicitly opt out of using tags by setting "Stable tag" to + //"trunk" or the name of the current branch. + if ( ($tag === $this->branch) || ($tag === 'trunk') ) { + return $api->getBranch($this->branch); + } + + $updateSource = $api->getTag($tag); + } + //Look for version-like tags. + if ( !$updateSource && ($this->branch === 'master') ) { + $updateSource = $api->getLatestTag(); + } + //If all else fails, use the specified branch itself. + if ( !$updateSource ) { + $updateSource = $api->getBranch($this->branch); + } + + return $updateSource; + } + public function setAuthentication($credentials) { $this->credentials = array_merge( array( @@ -100,6 +116,12 @@ if ( !class_exists('Puc_v4_BitBucket_PluginUpdateChecker') ): ), $credentials ); + return $this; + } + + public function setBranch($branchName = 'master') { + $this->branch = $branchName; + return $this; } /** diff --git a/Puc/v4/GitHub/Api.php b/Puc/v4/GitHub/Api.php index 7072280..31682fe 100644 --- a/Puc/v4/GitHub/Api.php +++ b/Puc/v4/GitHub/Api.php @@ -2,7 +2,7 @@ if ( !class_exists('Puc_v4_GitHub_Api', false) ): - class Puc_v4_GitHub_Api { + class Puc_v4_GitHub_Api extends Puc_v4_VcsApi { /** * @var string GitHub username. */ @@ -38,20 +38,36 @@ if ( !class_exists('Puc_v4_GitHub_Api', false) ): /** * Get the latest release from GitHub. * - * @return StdClass|null + * @return Puc_v4_VcsReference|null */ public function getLatestRelease() { $release = $this->api('/repos/:user/:repo/releases/latest'); if ( is_wp_error($release) || !is_object($release) || !isset($release->tag_name) ) { return null; } - return $release; + + $reference = new Puc_v4_VcsReference(array( + 'name' => $release->tag_name, + 'version' => ltrim($release->tag_name, 'v'), //Remove the "v" prefix from "v1.2.3". + 'downloadUrl' => $release->zipball_url, + 'updated' => $release->created_at, + )); + + if ( !empty($release->body) ) { + /** @noinspection PhpUndefinedClassInspection */ + $reference->changelog = Parsedown::instance()->text($release->body); + } + if ( isset($release->assets[0]) ) { + $reference->downloadCount = $release->assets[0]->download_count; + } + + return $reference; } /** * Get the tag that looks like the highest version number. * - * @return StdClass|null + * @return Puc_v4_VcsReference|null */ public function getLatestTag() { $tags = $this->api('/repos/:user/:repo/tags'); @@ -61,7 +77,13 @@ if ( !class_exists('Puc_v4_GitHub_Api', false) ): } usort($tags, array($this, 'compareTagNames')); //Sort from highest to lowest. - return $tags[0]; + + $tag = $tags[0]; + return new Puc_v4_VcsReference(array( + 'name' => $tag->name, + 'version' => ltrim($tag->name, 'v'), + 'downloadUrl' => $tag->zipball_url, + )); } /** @@ -81,6 +103,30 @@ if ( !class_exists('Puc_v4_GitHub_Api', false) ): return -version_compare($tag1->name, $tag2->name); } + /** + * Get a branch by name. + * + * @param string $branchName + * @return null|Puc_v4_VcsReference + */ + public function getBranch($branchName) { + $branch = $this->api('/repos/:user/:repo/branches/' . $branchName); + if ( is_wp_error($branch) || empty($branch) ) { + return null; + } + + $reference = new Puc_v4_VcsReference(array( + 'name' => $branch->name, + 'downloadUrl' => $this->buildArchiveDownloadUrl($branch->name), + )); + + if ( isset($branch->commit, $branch->commit->commit, $branch->commit->commit->author->date) ) { + $reference->updated = $branch->commit->commit->author->date; + } + + return $reference; + } + /** * Get the latest commit that changed the specified file. * @@ -102,6 +148,20 @@ if ( !class_exists('Puc_v4_GitHub_Api', false) ): return null; } + /** + * Get the timestamp of the latest commit that changed the specified branch or tag. + * + * @param string $ref Reference name (e.g. branch or tag). + * @return string|null + */ + public function getLatestCommitTime($ref) { + $commits = $this->api('/repos/:user/:repo/commits', array('sha' => $ref)); + if ( !is_wp_error($commits) && is_array($commits) && isset($commits[0]) ) { + return $commits[0]->commit->author->date; + } + return null; + } + /** * Perform a GitHub API request. * @@ -179,6 +239,17 @@ if ( !class_exists('Puc_v4_GitHub_Api', false) ): } return $url; } + + /** + * Get a specific tag. + * + * @param string $tagName + * @return Puc_v4_VcsReference|null + */ + public function getTag($tagName) { + //The current GitHub update checker doesn't use getTag, so didn't bother to implement it. + throw new LogicException('The ' . __METHOD__ . ' method is not implemented and should not be used.'); + } } endif; \ No newline at end of file diff --git a/Puc/v4/GitHub/PluginUpdateChecker.php b/Puc/v4/GitHub/PluginUpdateChecker.php index e686dee..31c0c9c 100644 --- a/Puc/v4/GitHub/PluginUpdateChecker.php +++ b/Puc/v4/GitHub/PluginUpdateChecker.php @@ -62,36 +62,24 @@ class Puc_v4_GitHub_PluginUpdateChecker extends Puc_v4_Plugin_UpdateChecker { $this->setInfoFromHeader($this->getPluginHeader(), $info); //Figure out which reference (tag or branch) we'll use to get the latest version of the plugin. - $ref = $this->branch; - if ( $this->branch === 'master' ) { - //Use the latest release. - $release = $api->getLatestRelease(); - if ( $release !== null ) { - $ref = $release->tag_name; - $info->version = ltrim($release->tag_name, 'v'); //Remove the "v" prefix from "v1.2.3". - $info->last_updated = $release->created_at; - $info->download_url = $release->zipball_url; + $updateSource = $this->chooseReference(); + if ( $updateSource ) { + $ref = $updateSource->name; + $info->version = $updateSource->version; + $info->last_updated = $updateSource->updated; + $info->download_url = $updateSource->downloadUrl; - if ( !empty($release->body) ) { - $info->sections['changelog'] = $this->parseMarkdown($release->body); - } - if ( isset($release->assets[0]) ) { - $info->downloaded = $release->assets[0]->download_count; - } - } else { - //Failing that, use the tag with the highest version number. - $tag = $api->getLatestTag(); - if ( $tag !== null ) { - $ref = $tag->name; - $info->version = $tag->name; - $info->download_url = $tag->zipball_url; - } + if ( !empty($updateSource->changelog) ) { + $info->sections['changelog'] = $updateSource->changelog; } + if ( isset($updateSource->downloadCount) ) { + $info->downloaded = $updateSource->downloadCount; + } + } else { + return null; } - if ( empty($info->download_url) ) { - $info->download_url = $api->buildArchiveDownloadUrl($ref); - } else if ( !empty($this->accessToken) ) { + if ( !empty($info->download_url) && !empty($this->accessToken) ) { $info->download_url = add_query_arg('access_token', $this->accessToken, $info->download_url); } @@ -112,64 +100,42 @@ class Puc_v4_GitHub_PluginUpdateChecker extends Puc_v4_Plugin_UpdateChecker { //The changelog might be in a separate file. if ( empty($info->sections['changelog']) ) { - $info->sections['changelog'] = $this->getRemoteChangelog($ref); + $info->sections['changelog'] = $api->getRemoteChangelog($ref, dirname($this->getAbsolutePath())); if ( empty($info->sections['changelog']) ) { $info->sections['changelog'] = __('There is no changelog available.', 'plugin-update-checker'); } } if ( empty($info->last_updated) ) { - //Fetch the latest commit that changed the main plugin file and use it as the "last_updated" date. - //It's reasonable to assume that every update will change the version number in that file. - $latestCommit = $api->getLatestCommit($mainPluginFile, $ref); - if ( $latestCommit !== null ) { - $info->last_updated = $latestCommit->commit->author->date; - } + //Fetch the latest commit that changed the tag/branch and use it as the "last_updated" date. + $info->last_updated = $api->getLatestCommitTime($ref); } $info = apply_filters($this->getUniqueName('request_info_result'), $info, null); return $info; } - protected function getRemoteChangelog($ref = '') { - $filename = $this->getChangelogFilename(); - if ( empty($filename) ) { - return null; - } - - $changelog = $this->api->getRemoteFile($filename, $ref); - if ( $changelog === null ) { - return null; - } - return $this->parseMarkdown($changelog); - } - - protected function getChangelogFilename() { - $pluginDirectory = dirname($this->pluginAbsolutePath); - if ( empty($this->pluginAbsolutePath) || !is_dir($pluginDirectory) || ($pluginDirectory === '.') ) { - return null; - } - - $possibleNames = array('CHANGES.md', 'CHANGELOG.md', 'changes.md', 'changelog.md'); - $files = scandir($pluginDirectory); - $foundNames = array_intersect($possibleNames, $files); - - if ( !empty($foundNames) ) { - return reset($foundNames); - } - return null; - } - /** - * Convert Markdown to HTML. - * - * @param string $markdown - * @return string + * @return Puc_v4_VcsReference|null */ - protected function parseMarkdown($markdown) { - /** @noinspection PhpUndefinedClassInspection */ - $instance = Parsedown::instance(); - return $instance->text($markdown); + protected function chooseReference() { + $api = $this->api; + $updateSource = null; + + if ( $this->branch === 'master' ) { + //Use the latest release. + $updateSource = $api->getLatestRelease(); + if ( $updateSource === null ) { + //Failing that, use the tag with the highest version number. + $updateSource = $api->getLatestTag(); + } + } + //Alternatively, just use the branch itself. + if ( empty($ref) ) { + $updateSource = $api->getBranch($this->branch); + } + + return $updateSource; } /** @@ -221,13 +187,11 @@ class Puc_v4_GitHub_PluginUpdateChecker extends Puc_v4_Plugin_UpdateChecker { * @param Puc_v4_Plugin_Info $pluginInfo */ protected function setInfoFromRemoteReadme($ref, $pluginInfo) { - $readmeTxt = $this->api->getRemoteFile('readme.txt', $ref); + $readme = $this->api->getRemoteReadme($ref); if ( empty($readmeTxt) ) { return; } - $readme = $this->parseReadme($readmeTxt); - if ( isset($readme['sections']) ) { $pluginInfo->sections = array_merge($pluginInfo->sections, $readme['sections']); } @@ -243,11 +207,6 @@ class Puc_v4_GitHub_PluginUpdateChecker extends Puc_v4_Plugin_UpdateChecker { } } - protected function parseReadme($content) { - $parser = new PucReadmeParser(); - return $parser->parse_readme_contents($content); - } - /** * Check if the currently installed version has a readme.txt file. * diff --git a/Puc/v4/GitHub/ThemeUpdateChecker.php b/Puc/v4/GitHub/ThemeUpdateChecker.php index 3e9bcfe..4721759 100644 --- a/Puc/v4/GitHub/ThemeUpdateChecker.php +++ b/Puc/v4/GitHub/ThemeUpdateChecker.php @@ -23,16 +23,16 @@ if ( !class_exists('Puc_v4_GitHub_ThemeUpdateChecker', false) ): //Use the latest release. $release = $api->getLatestRelease(); if ( $release !== null ) { - $ref = $release->tag_name; - $update->version = ltrim($release->tag_name, 'v'); //Remove the "v" prefix from "v1.2.3". - $update->download_url = $release->zipball_url; + $ref = $release->name; + $update->version = ltrim($release->name, 'v'); //Remove the "v" prefix from "v1.2.3". + $update->download_url = $release->downloadUrl; } else { //Failing that, use the tag with the highest version number. $tag = $api->getLatestTag(); if ( $tag !== null ) { $ref = $tag->name; - $update->version = $tag->name; - $update->download_url = $tag->zipball_url; + $update->version = $tag->version; + $update->download_url = $tag->downloadUrl; } } } diff --git a/Puc/v4/VcsApi.php b/Puc/v4/VcsApi.php new file mode 100644 index 0000000..d9a9a96 --- /dev/null +++ b/Puc/v4/VcsApi.php @@ -0,0 +1,142 @@ +getRemoteFile('readme.txt', $ref); + if ( empty($fileContents) ) { + return array(); + } + + $parser = new PucReadmeParser(); + return $parser->parse_readme_contents($fileContents); + } + + /** + * Get a branch. + * + * @param string $branchName + * @return Puc_v4_VcsReference|null + */ + abstract public function getBranch($branchName); + + /** + * Get a specific tag. + * + * @param string $tagName + * @return Puc_v4_VcsReference|null + */ + abstract public function getTag($tagName); + + /** + * Get the tag that looks like the highest version number. + * (Implementations should skip pre-release versions if possible.) + * + * @return Puc_v4_VcsReference|null + */ + abstract public function getLatestTag(); + + /** + * Check if a tag name string looks like a version number. + * + * @param string $name + * @return bool + */ + protected function looksLikeVersion($name) { + //Tag names may be prefixed with "v", e.g. "v1.2.3". + $name = ltrim($name, 'v'); + + //The version string must start with a number. + if ( !is_numeric($name) ) { + return false; + } + + //The goal is to accept any SemVer-compatible or "PHP-standardized" version number. + return (preg_match('@^(\d{1,5}?)(\.\d{1,10}?){0,4}?($|[abrdp+_\-]|\s)@i', $name) === 1); + } + + /** + * Compare two tag names as if they were version number. + * + * @param string $tag1 + * @param string $tag2 + * @return int + */ + protected function compareTagNames($tag1, $tag2) { + if ( !isset($tag1) ) { + return 1; + } + if ( !isset($tag2) ) { + return -1; + } + return -version_compare(ltrim($tag1, 'v'), ltrim($tag2, 'v')); + } + + /** + * Get the contents of a file from a specific branch or tag. + * + * @param string $path File name. + * @param string $ref + * @return null|string Either the contents of the file, or null if the file doesn't exist or there's an error. + */ + abstract public function getRemoteFile($path, $ref = 'master'); + + /** + * Get the timestamp of the latest commit that changed the specified branch or tag. + * + * @param string $ref Reference name (e.g. branch or tag). + * @return string|null + */ + abstract public function getLatestCommitTime($ref); + + /** + * Get the contents of the changelog file from the repository. + * + * @param string $ref + * @param string $localDirectory Full path to the local plugin or theme directory. + * @return null|string The HTML contents of the changelog. + */ + public function getRemoteChangelog($ref, $localDirectory) { + $filename = $this->findChangelogName($localDirectory); + if ( empty($filename) ) { + return null; + } + + $changelog = $this->getRemoteFile($filename, $ref); + if ( $changelog === null ) { + return null; + } + + return Parsedown::instance()->text($changelog); + } + + /** + * Guess the name of the changelog file. + * + * @param string $directory + * @return string|null + */ + protected function findChangelogName($directory) { + if ( empty($directory) || !is_dir($directory) || ($directory === '.') ) { + return null; + } + + $possibleNames = array('CHANGES.md', 'CHANGELOG.md', 'changes.md', 'changelog.md'); + $files = scandir($directory); + $foundNames = array_intersect($possibleNames, $files); + + if ( !empty($foundNames) ) { + return reset($foundNames); + } + return null; + } + } + +endif; diff --git a/Puc/v4/VcsReference.php b/Puc/v4/VcsReference.php new file mode 100644 index 0000000..e7047fa --- /dev/null +++ b/Puc/v4/VcsReference.php @@ -0,0 +1,49 @@ +properties = $properties; + } + + /** + * @param string $name + * @return mixed|null + */ + function __get($name) { + return array_key_exists($name, $this->properties) ? $this->properties[$name] : null; + } + + /** + * @param string $name + * @param mixed $value + */ + function __set($name, $value) { + $this->properties[$name] = $value; + } + + /** + * @param string $name + * @return bool + */ + function __isset($name) { + return isset($this->properties[$name]); + } + + } + +endif; \ No newline at end of file From f62a3d40fe7341d5724752ffde59ac84522a4d31 Mon Sep 17 00:00:00 2001 From: Yahnis Elsts Date: Sat, 24 Dec 2016 15:14:34 +0200 Subject: [PATCH 16/44] Simplify tag sorting. We don't need two different implementation for GitHub and BitBucket, they use the same property name. But lets make the property configurable anyway in case other APIs do differ. --- Puc/v4/BitBucket/Api.php | 17 ----------------- Puc/v4/GitHub/Api.php | 17 ----------------- Puc/v4/VcsApi.php | 16 ++++++++++------ 3 files changed, 10 insertions(+), 40 deletions(-) diff --git a/Puc/v4/BitBucket/Api.php b/Puc/v4/BitBucket/Api.php index 591f8ff..c7c4a46 100644 --- a/Puc/v4/BitBucket/Api.php +++ b/Puc/v4/BitBucket/Api.php @@ -115,23 +115,6 @@ if ( !class_exists('Puc_v4_BitBucket_Api', false) ): return isset($tag->name) && $this->looksLikeVersion($tag->name); } - /** - * Compare two BitBucket tags as if they were version number. - * - * @param stdClass $tag1 - * @param stdClass $tag2 - * @return int - */ - protected function compareTagNames($tag1, $tag2) { - if ( !isset($tag1->name) ) { - return 1; - } - if ( !isset($tag2->name) ) { - return -1; - } - return -version_compare(ltrim($tag1->name, 'v'), ltrim($tag2->name, 'v')); - } - /** * Get the contents of a file from a specific branch or tag. * diff --git a/Puc/v4/GitHub/Api.php b/Puc/v4/GitHub/Api.php index 31682fe..2dc3558 100644 --- a/Puc/v4/GitHub/Api.php +++ b/Puc/v4/GitHub/Api.php @@ -86,23 +86,6 @@ if ( !class_exists('Puc_v4_GitHub_Api', false) ): )); } - /** - * Compare two GitHub tags as if they were version number. - * - * @param string $tag1 - * @param string $tag2 - * @return int - */ - protected function compareTagNames($tag1, $tag2) { - if ( !isset($tag1->name) ) { - return 1; - } - if ( !isset($tag2->name) ) { - return -1; - } - return -version_compare($tag1->name, $tag2->name); - } - /** * Get a branch by name. * diff --git a/Puc/v4/VcsApi.php b/Puc/v4/VcsApi.php index d9a9a96..ecfafff 100644 --- a/Puc/v4/VcsApi.php +++ b/Puc/v4/VcsApi.php @@ -2,6 +2,8 @@ if ( !class_exists('Puc_v4_VcsApi') ): abstract class Puc_v4_VcsApi { + protected $tagNameProperty = 'name'; + /** * Get the readme.txt file from the remote repository and parse it * according to the plugin readme standard. @@ -63,20 +65,21 @@ if ( !class_exists('Puc_v4_VcsApi') ): } /** - * Compare two tag names as if they were version number. + * Compare two tags as if they were version number. * - * @param string $tag1 - * @param string $tag2 + * @param stdClass $tag1 Tag object. + * @param stdClass $tag2 Another tag object. * @return int */ protected function compareTagNames($tag1, $tag2) { - if ( !isset($tag1) ) { + $property = $this->tagNameProperty; + if ( !isset($tag1->$property) ) { return 1; } - if ( !isset($tag2) ) { + if ( !isset($tag2->$property) ) { return -1; } - return -version_compare(ltrim($tag1, 'v'), ltrim($tag2, 'v')); + return -version_compare(ltrim($tag1->$property, 'v'), ltrim($tag2->$property, 'v')); } /** @@ -114,6 +117,7 @@ if ( !class_exists('Puc_v4_VcsApi') ): return null; } + /** @noinspection PhpUndefinedClassInspection */ return Parsedown::instance()->text($changelog); } From cde890f5d4fc09dbc3e8ebe4c21ec399fb78586f Mon Sep 17 00:00:00 2001 From: Yahnis Elsts Date: Sat, 24 Dec 2016 16:03:58 +0200 Subject: [PATCH 17/44] Minor: Update comments. --- Puc/v4/Factory.php | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Puc/v4/Factory.php b/Puc/v4/Factory.php index 3823829..7293299 100644 --- a/Puc/v4/Factory.php +++ b/Puc/v4/Factory.php @@ -8,10 +8,8 @@ if ( !class_exists('Puc_v4_Factory', false) ): * and 4.1), this factory will always use the latest available minor version. Register class * versions by calling {@link PucFactory::addVersion()}. * - * At the moment it can only build instances of the PluginUpdateChecker class. Other classes - * are intended mainly for internal use and refer directly to specific implementations. If you - * want to instantiate one of them anyway, you can use {@link PucFactory::getLatestClassVersion()} - * to get the class name and then create it with new $class(...). + * At the moment it can only build instances of the UpdateChecker class. Other classes are + * intended mainly for internal use and refer directly to specific implementations. */ class Puc_v4_Factory { protected static $classVersions = array(); From cdf2d22243d935dd46df9a75da73d6b62d2ebe51 Mon Sep 17 00:00:00 2001 From: Yahnis Elsts Date: Tue, 27 Dec 2016 18:03:06 +0200 Subject: [PATCH 18/44] Refactoring GitHub and BitBucket support. Move most GitHub and BitBucket stuff to a general "VCS checker" class and put service-specific logic in API classes that follow a common interface. Rationale: Upon further reflection, there's no need to have different theme & plugin checker implementations for each Git hosting service. The overall update detection algorithm stays the same. Only the API and authentication are different. Not entirely happy with the code duplication in Vcs_PluginUpdateChecker and Vcs_ThemeUpdateChecker. Traits would be one solution, but can't use that in PHP 5.2. There's probably a "good enough" way to achieve the same thing through composition, but I haven't figured it out yet. For private GH repositories, use setAuthentication('access_token_here') instead of setAccessToken(). --- Puc/v4/Factory.php | 66 +++++- Puc/v4/GitHub/PluginUpdateChecker.php | 224 ------------------ Puc/v4/GitHub/ThemeUpdateChecker.php | 99 -------- Puc/v4/{VcsApi.php => Vcs/Api.php} | 91 ++++++- Puc/v4/Vcs/BaseChecker.php | 22 ++ .../Api.php => Vcs/BitBucketApi.php} | 100 +++++--- Puc/v4/{GitHub/Api.php => Vcs/GitHubApi.php} | 69 ++++-- .../PluginUpdateChecker.php | 109 ++++----- .../{VcsReference.php => Vcs/Reference.php} | 4 +- Puc/v4/Vcs/ThemeUpdateChecker.php | 99 ++++++++ plugin-update-checker.php | 8 +- 11 files changed, 434 insertions(+), 457 deletions(-) delete mode 100644 Puc/v4/GitHub/PluginUpdateChecker.php delete mode 100644 Puc/v4/GitHub/ThemeUpdateChecker.php rename Puc/v4/{VcsApi.php => Vcs/Api.php} (62%) create mode 100644 Puc/v4/Vcs/BaseChecker.php rename Puc/v4/{BitBucket/Api.php => Vcs/BitBucketApi.php} (65%) rename Puc/v4/{GitHub/Api.php => Vcs/GitHubApi.php} (75%) rename Puc/v4/{BitBucket => Vcs}/PluginUpdateChecker.php (68%) rename Puc/v4/{VcsReference.php => Vcs/Reference.php} (91%) create mode 100644 Puc/v4/Vcs/ThemeUpdateChecker.php diff --git a/Puc/v4/Factory.php b/Puc/v4/Factory.php index 7293299..147150e 100644 --- a/Puc/v4/Factory.php +++ b/Puc/v4/Factory.php @@ -23,13 +23,13 @@ if ( !class_exists('Puc_v4_Factory', false) ): * * @see PluginUpdateChecker::__construct() * - * @param string $metadataUrl The URL of the metadata file, or a GitHub repository, etc. + * @param string $metadataUrl The URL of the metadata file, a GitHub repository, or another supported update source. * @param string $fullPath Full path to the main plugin file or to the theme directory. * @param string $slug Custom slug. Defaults to the name of the main plugin file or the theme directory. * @param int $checkPeriod How often to check for updates (in hours). * @param string $optionName Where to store book-keeping info about update checks. * @param string $muPluginFile The plugin filename relative to the mu-plugins directory. - * @return Puc_v4_Plugin_UpdateChecker|Puc_v4_Theme_UpdateChecker + * @return Puc_v4_Plugin_UpdateChecker|Puc_v4_Theme_UpdateChecker|Puc_v4_Vcs_BaseChecker */ public static function buildUpdateChecker($metadataUrl, $fullPath, $slug = '', $checkPeriod = 12, $optionName = '', $muPluginFile = '') { $fullPath = wp_normalize_path($fullPath); @@ -68,31 +68,63 @@ if ( !class_exists('Puc_v4_Factory', false) ): } } - $class = null; + $checkerClass = null; + $apiClass = null; if ( empty($service) ) { //The default is to get update information from a remote JSON file. - $class = $type . '_UpdateChecker'; + $checkerClass = $type . '_UpdateChecker'; } else { - $class = $service . '_' . $type . 'UpdateChecker'; + //You can also use a VCS repository like GitHub. + $checkerClass = 'Vcs_' . $type . 'UpdateChecker'; + $apiClass = $service . 'Api'; } - if ( !isset(self::$classVersions[$class][self::$greatestCompatVersion]) ) { + $checkerClass = self::getCompatibleClass($checkerClass); + if ( !$checkerClass ) { trigger_error( sprintf( - 'PUC %s does not support updates for %ss hosted on %s', + 'PUC %s does not support updates for %ss %s', htmlentities(self::$greatestCompatVersion), strtolower($type), - $service + $service ? ('hosted on ' . htmlentities($service)) : 'using JSON metadata' ), E_USER_ERROR ); return null; } - $class = self::$classVersions[$class][self::$greatestCompatVersion]; - return new $class($metadataUrl, $id, $slug, $checkPeriod, $optionName, $muPluginFile); + if ( !isset($apiClass) ) { + //Plain old update checker. + return new $checkerClass($metadataUrl, $id, $slug, $checkPeriod, $optionName, $muPluginFile); + } else { + //VCS checker + an API client. + $apiClass = self::getCompatibleClass($apiClass); + if ( !$apiClass ) { + trigger_error(sprintf( + 'PUC %s does not support %s', + htmlentities(self::$greatestCompatVersion), + htmlentities($service) + ), E_USER_ERROR); + return null; + } + + return new $checkerClass( + new $apiClass($metadataUrl), + $id, + $slug, + $checkPeriod, + $optionName, + $muPluginFile + ); + } } + /** + * Check if the path points to something inside the "plugins" or "mu-plugins" directories. + * + * @param string $absolutePath + * @return bool + */ protected static function isPluginFile($absolutePath) { $pluginDir = wp_normalize_path(WP_PLUGIN_DIR); $muPluginDir = wp_normalize_path(WPMU_PLUGIN_DIR); @@ -101,6 +133,20 @@ if ( !class_exists('Puc_v4_Factory', false) ): return (strpos($absolutePath, $pluginDir) === 0) || (strpos($absolutePath, $muPluginDir) === 0); } + /** + * Get the latest version of the specified class that has the same major version number + * as this factory class. + * + * @param string $class Partial class name. + * @return string|null Full class name. + */ + protected static function getCompatibleClass($class) { + if ( isset(self::$classVersions[$class][self::$greatestCompatVersion]) ) { + return self::$classVersions[$class][self::$greatestCompatVersion]; + } + return null; + } + /** * Get the specific class name for the latest available version of a class. * diff --git a/Puc/v4/GitHub/PluginUpdateChecker.php b/Puc/v4/GitHub/PluginUpdateChecker.php deleted file mode 100644 index 31c0c9c..0000000 --- a/Puc/v4/GitHub/PluginUpdateChecker.php +++ /dev/null @@ -1,224 +0,0 @@ -repositoryUrl = $repositoryUrl; - parent::__construct($repositoryUrl, $pluginFile, $slug, $checkPeriod, $optionName, $muPluginFile); - } - - /** - * Set the GitHub branch to use for updates. Defaults to 'master'. - * - * @param string $branch - * @return $this - */ - public function setBranch($branch) { - $this->branch = empty($branch) ? 'master' : $branch; - return $this; - } - - /** - * Retrieve details about the latest plugin version from GitHub. - * - * @param array $unusedQueryArgs Unused. - * @return Puc_v4_Plugin_Info - */ - public function requestInfo($unusedQueryArgs = array()) { - $api = $this->api = new Puc_v4_GitHub_Api($this->repositoryUrl, $this->accessToken); - - $info = new Puc_v4_Plugin_Info(); - $info->filename = $this->pluginFile; - $info->slug = $this->slug; - - $this->setInfoFromHeader($this->getPluginHeader(), $info); - - //Figure out which reference (tag or branch) we'll use to get the latest version of the plugin. - $updateSource = $this->chooseReference(); - if ( $updateSource ) { - $ref = $updateSource->name; - $info->version = $updateSource->version; - $info->last_updated = $updateSource->updated; - $info->download_url = $updateSource->downloadUrl; - - if ( !empty($updateSource->changelog) ) { - $info->sections['changelog'] = $updateSource->changelog; - } - if ( isset($updateSource->downloadCount) ) { - $info->downloaded = $updateSource->downloadCount; - } - } else { - return null; - } - - if ( !empty($info->download_url) && !empty($this->accessToken) ) { - $info->download_url = add_query_arg('access_token', $this->accessToken, $info->download_url); - } - - //Get headers from the main plugin file in this branch/tag. Its "Version" header and other metadata - //are what the WordPress install will actually see after upgrading, so they take precedence over releases/tags. - $mainPluginFile = basename($this->pluginFile); - $remotePlugin = $api->getRemoteFile($mainPluginFile, $ref); - if ( !empty($remotePlugin) ) { - $remoteHeader = $this->getFileHeader($remotePlugin); - $this->setInfoFromHeader($remoteHeader, $info); - } - - //Try parsing readme.txt. If it's formatted according to WordPress.org standards, it will contain - //a lot of useful information like the required/tested WP version, changelog, and so on. - if ( $this->readmeTxtExistsLocally() ) { - $this->setInfoFromRemoteReadme($ref, $info); - } - - //The changelog might be in a separate file. - if ( empty($info->sections['changelog']) ) { - $info->sections['changelog'] = $api->getRemoteChangelog($ref, dirname($this->getAbsolutePath())); - if ( empty($info->sections['changelog']) ) { - $info->sections['changelog'] = __('There is no changelog available.', 'plugin-update-checker'); - } - } - - if ( empty($info->last_updated) ) { - //Fetch the latest commit that changed the tag/branch and use it as the "last_updated" date. - $info->last_updated = $api->getLatestCommitTime($ref); - } - - $info = apply_filters($this->getUniqueName('request_info_result'), $info, null); - return $info; - } - - /** - * @return Puc_v4_VcsReference|null - */ - protected function chooseReference() { - $api = $this->api; - $updateSource = null; - - if ( $this->branch === 'master' ) { - //Use the latest release. - $updateSource = $api->getLatestRelease(); - if ( $updateSource === null ) { - //Failing that, use the tag with the highest version number. - $updateSource = $api->getLatestTag(); - } - } - //Alternatively, just use the branch itself. - if ( empty($ref) ) { - $updateSource = $api->getBranch($this->branch); - } - - return $updateSource; - } - - /** - * Set the access token that will be used to make authenticated GitHub API requests. - * - * @param string $accessToken - * @return $this - */ - public function setAccessToken($accessToken) { - $this->accessToken = $accessToken; - return $this; - } - - /** - * Copy plugin metadata from a file header to a PluginInfo object. - * - * @param array $fileHeader - * @param Puc_v4_Plugin_Info $pluginInfo - */ - protected function setInfoFromHeader($fileHeader, $pluginInfo) { - $headerToPropertyMap = array( - 'Version' => 'version', - 'Name' => 'name', - 'PluginURI' => 'homepage', - 'Author' => 'author', - 'AuthorName' => 'author', - 'AuthorURI' => 'author_homepage', - - 'Requires WP' => 'requires', - 'Tested WP' => 'tested', - 'Requires at least' => 'requires', - 'Tested up to' => 'tested', - ); - foreach ($headerToPropertyMap as $headerName => $property) { - if ( isset($fileHeader[$headerName]) && !empty($fileHeader[$headerName]) ) { - $pluginInfo->$property = $fileHeader[$headerName]; - } - } - - if ( !empty($fileHeader['Description']) ) { - $pluginInfo->sections['description'] = $fileHeader['Description']; - } - } - - /** - * Copy plugin metadata from the remote readme.txt file. - * - * @param string $ref GitHub tag or branch where to look for the readme. - * @param Puc_v4_Plugin_Info $pluginInfo - */ - protected function setInfoFromRemoteReadme($ref, $pluginInfo) { - $readme = $this->api->getRemoteReadme($ref); - if ( empty($readmeTxt) ) { - return; - } - - if ( isset($readme['sections']) ) { - $pluginInfo->sections = array_merge($pluginInfo->sections, $readme['sections']); - } - if ( !empty($readme['tested_up_to']) ) { - $pluginInfo->tested = $readme['tested_up_to']; - } - if ( !empty($readme['requires_at_least']) ) { - $pluginInfo->requires = $readme['requires_at_least']; - } - - if ( isset($readme['upgrade_notice'], $readme['upgrade_notice'][$pluginInfo->version]) ) { - $pluginInfo->upgrade_notice = $readme['upgrade_notice'][$pluginInfo->version]; - } - } - - /** - * Check if the currently installed version has a readme.txt file. - * - * @return bool - */ - protected function readmeTxtExistsLocally() { - $pluginDirectory = dirname($this->pluginAbsolutePath); - if ( empty($this->pluginAbsolutePath) || !is_dir($pluginDirectory) || ($pluginDirectory === '.') ) { - return false; - } - return is_file($pluginDirectory . '/readme.txt'); - } -} - -endif; \ No newline at end of file diff --git a/Puc/v4/GitHub/ThemeUpdateChecker.php b/Puc/v4/GitHub/ThemeUpdateChecker.php deleted file mode 100644 index 4721759..0000000 --- a/Puc/v4/GitHub/ThemeUpdateChecker.php +++ /dev/null @@ -1,99 +0,0 @@ -repositoryUrl = $repositoryUrl; - parent::__construct($repositoryUrl, $stylesheet, $customSlug, $checkPeriod, $optionName); - } - - public function requestUpdate() { - $api = new Puc_v4_GitHub_Api($this->repositoryUrl, $this->accessToken); - - $update = new Puc_v4_Theme_Update(); - $update->slug = $this->slug; - - //Figure out which reference (tag or branch) we'll use to get the latest version of the theme. - $ref = $this->branch; - if ( $this->branch === 'master' ) { - //Use the latest release. - $release = $api->getLatestRelease(); - if ( $release !== null ) { - $ref = $release->name; - $update->version = ltrim($release->name, 'v'); //Remove the "v" prefix from "v1.2.3". - $update->download_url = $release->downloadUrl; - } else { - //Failing that, use the tag with the highest version number. - $tag = $api->getLatestTag(); - if ( $tag !== null ) { - $ref = $tag->name; - $update->version = $tag->version; - $update->download_url = $tag->downloadUrl; - } - } - } - - if ( empty($update->download_url) ) { - $update->download_url = $api->buildArchiveDownloadUrl($ref); - } else if ( !empty($this->accessToken) ) { - $update->download_url = add_query_arg('access_token', $this->accessToken, $update->download_url); - } - - //Get headers from the main stylesheet in this branch/tag. Its "Version" header and other metadata - //are what the WordPress install will actually see after upgrading, so they take precedence over releases/tags. - $remoteStylesheet = $api->getRemoteFile('style.css', $ref); - if ( !empty($remoteStylesheet) ) { - $remoteHeader = $this->getFileHeader($remoteStylesheet); - if ( !empty($remoteHeader['Version']) ) { - $update->version = $remoteHeader['Version']; - } - if ( !empty($remoteHeader['ThemeURI']) ) { - $update->details_url = $remoteHeader['ThemeURI']; - } - } - - //The details URL defaults to the Theme URI header or the repository URL. - if ( empty($update->details_url) ) { - $update->details_url = $this->theme->get('ThemeURI'); - } - if ( empty($update->details_url) ) { - $update->details_url = $this->repositoryUrl; - } - - if ( empty($update->version) ) { - //It looks like we didn't find a valid update after all. - $update = null; - } - - $update = $this->filterUpdateResult($update); - return $update; - } - - /** - * Set the GitHub branch to use for updates. Defaults to 'master'. - * - * @param string $branch - * @return $this - */ - public function setBranch($branch) { - $this->branch = empty($branch) ? 'master' : $branch; - return $this; - } - - /** - * Set the access token that will be used to make authenticated GitHub API requests. - * - * @param string $accessToken - * @return $this - */ - public function setAccessToken($accessToken) { - $this->accessToken = $accessToken; - return $this; - } - } - -endif; \ No newline at end of file diff --git a/Puc/v4/VcsApi.php b/Puc/v4/Vcs/Api.php similarity index 62% rename from Puc/v4/VcsApi.php rename to Puc/v4/Vcs/Api.php index ecfafff..7d5dd32 100644 --- a/Puc/v4/VcsApi.php +++ b/Puc/v4/Vcs/Api.php @@ -1,9 +1,46 @@ repositoryUrl = $repositoryUrl; + $this->setAuthentication($credentials); + } + + /** + * @return string + */ + public function getRepositoryUrl() { + return $this->repositoryUrl; + } + + /** + * Figure out which reference (i.e tag or branch) contains the latest version. + * + * @param string $configBranch Start looking in this branch. + * @param bool $useStableTag + * @return null|Puc_v4_Vcs_Reference + */ + abstract public function chooseReference($configBranch, $useStableTag = true); + /** * Get the readme.txt file from the remote repository and parse it * according to the plugin readme standard. @@ -25,7 +62,7 @@ if ( !class_exists('Puc_v4_VcsApi') ): * Get a branch. * * @param string $branchName - * @return Puc_v4_VcsReference|null + * @return Puc_v4_Vcs_Reference|null */ abstract public function getBranch($branchName); @@ -33,7 +70,7 @@ if ( !class_exists('Puc_v4_VcsApi') ): * Get a specific tag. * * @param string $tagName - * @return Puc_v4_VcsReference|null + * @return Puc_v4_Vcs_Reference|null */ abstract public function getTag($tagName); @@ -41,7 +78,7 @@ if ( !class_exists('Puc_v4_VcsApi') ): * Get the tag that looks like the highest version number. * (Implementations should skip pre-release versions if possible.) * - * @return Puc_v4_VcsReference|null + * @return Puc_v4_Vcs_Reference|null */ abstract public function getLatestTag(); @@ -64,6 +101,33 @@ if ( !class_exists('Puc_v4_VcsApi') ): return (preg_match('@^(\d{1,5}?)(\.\d{1,10}?){0,4}?($|[abrdp+_\-]|\s)@i', $name) === 1); } + /** + * Check if a tag appears to be named like a version number. + * + * @param stdClass $tag + * @return bool + */ + protected function isVersionTag($tag) { + $property = $this->tagNameProperty; + return isset($tag->$property) && $this->looksLikeVersion($tag->$property); + } + + /** + * Sort a list of tags as if they were version numbers. + * Tags that don't look like version number will be removed. + * + * @param stdClass[] $tags Array of tag objects. + * @return stdClass[] Filtered array of tags sorted in descending order. + */ + protected function sortTagsByVersion($tags) { + //Keep only those tags that look like version numbers. + $versionTags = array_filter($tags, array($this, 'isVersionTag')); + //Sort them in descending order. + usort($versionTags, array($this, 'compareTagNames')); + + return $versionTags; + } + /** * Compare two tags as if they were version number. * @@ -141,6 +205,23 @@ if ( !class_exists('Puc_v4_VcsApi') ): } return null; } + + /** + * Set authentication credentials. + * + * @param $credentials + */ + public function setAuthentication($credentials) { + $this->credentials = $credentials; + } + + /** + * @param string $url + * @return string + */ + public function signDownloadUrl($url) { + return $url; + } } endif; diff --git a/Puc/v4/Vcs/BaseChecker.php b/Puc/v4/Vcs/BaseChecker.php new file mode 100644 index 0000000..77a8919 --- /dev/null +++ b/Puc/v4/Vcs/BaseChecker.php @@ -0,0 +1,22 @@ +repositoryUrl = $repositoryUrl; - $path = @parse_url($repositoryUrl, PHP_URL_PATH); if ( preg_match('@^/?(?P[^/]+?)/(?P[^/#?&]+?)/?$@', $path, $matches) ) { $this->username = $matches['username']; @@ -33,12 +26,45 @@ if ( !class_exists('Puc_v4_BitBucket_Api', false) ): throw new InvalidArgumentException('Invalid BitBucket repository URL: "' . $repositoryUrl . '"'); } - if ( !empty($credentials) && !empty($credentials['consumer_key']) ) { - $this->oauth = new Puc_v4_OAuthSignature( - $credentials['consumer_key'], - $credentials['consumer_secret'] - ); + parent::__construct($repositoryUrl, $credentials); + } + + /** + * Figure out which reference (i.e tag or branch) contains the latest version. + * + * @param string $configBranch Start looking in this branch. + * @param bool $useStableTag + * @return null|Puc_v4_Vcs_Reference + */ + public function chooseReference($configBranch, $useStableTag = true) { + $updateSource = null; + + //Check if there's a "Stable tag: 1.2.3" header that points to a valid tag. + if ( $useStableTag ) { + $remoteReadme = $this->getRemoteReadme($configBranch); + if ( !empty($remoteReadme['stable_tag']) ) { + $tag = $remoteReadme['stable_tag']; + + //You can explicitly opt out of using tags by setting "Stable tag" to + //"trunk" or the name of the current branch. + if ( ($tag === $configBranch) || ($tag === 'trunk') ) { + return $this->getBranch($configBranch); + } + + $updateSource = $this->getTag($tag); + } } + + //Look for version-like tags. + if ( !$updateSource && ($configBranch === 'master') ) { + $updateSource = $this->getLatestTag(); + } + //If all else fails, use the specified branch itself. + if ( !$updateSource ) { + $updateSource = $this->getBranch($configBranch); + } + + return $updateSource; } public function getBranch($branchName) { @@ -47,7 +73,7 @@ if ( !class_exists('Puc_v4_BitBucket_Api', false) ): return null; } - return new Puc_v4_VcsReference(array( + return new Puc_v4_Vcs_Reference(array( 'name' => $branch->name, 'updated' => $branch->target->date, 'downloadUrl' => $this->getDownloadUrl($branch->name), @@ -58,7 +84,7 @@ if ( !class_exists('Puc_v4_BitBucket_Api', false) ): * Get a specific tag. * * @param string $tagName - * @return Puc_v4_VcsReference|null + * @return Puc_v4_Vcs_Reference|null */ public function getTag($tagName) { $tag = $this->api('/refs/tags/' . $tagName); @@ -66,7 +92,7 @@ if ( !class_exists('Puc_v4_BitBucket_Api', false) ): return null; } - return new Puc_v4_VcsReference(array( + return new Puc_v4_Vcs_Reference(array( 'name' => $tag->name, 'version' => ltrim($tag->name, 'v'), 'updated' => $tag->target->date, @@ -77,7 +103,7 @@ if ( !class_exists('Puc_v4_BitBucket_Api', false) ): /** * Get the tag that looks like the highest version number. * - * @return Puc_v4_VcsReference|null + * @return Puc_v4_Vcs_Reference|null */ public function getLatestTag() { $tags = $this->api('/refs/tags'); @@ -85,15 +111,13 @@ if ( !class_exists('Puc_v4_BitBucket_Api', false) ): return null; } - //Keep only those tags that look like version numbers. - $versionTags = array_filter($tags->values, array($this, 'isVersionTag')); - //Sort them in descending order. - usort($versionTags, array($this, 'compareTagNames')); + //Filter and sort the list of tags. + $versionTags = $this->sortTagsByVersion($tags->values); //Return the first result. if ( !empty($versionTags) ) { $tag = $versionTags[0]; - return new Puc_v4_VcsReference(array( + return new Puc_v4_Vcs_Reference(array( 'name' => $tag->name, 'version' => ltrim($tag->name, 'v'), 'updated' => $tag->target->date, @@ -111,10 +135,6 @@ if ( !class_exists('Puc_v4_BitBucket_Api', false) ): return trailingslashit($this->repositoryUrl) . 'get/' . $ref . '.zip'; } - protected function isVersionTag($tag) { - return isset($tag->name) && $this->looksLikeVersion($tag->name); - } - /** * Get the contents of a file from a specific branch or tag. * @@ -185,6 +205,32 @@ if ( !class_exists('Puc_v4_BitBucket_Api', false) ): 'BitBucket API error. HTTP status: ' . $code ); } + + /** + * @param array $credentials + */ + public function setAuthentication($credentials) { + parent::setAuthentication($credentials); + + if ( !empty($credentials) && !empty($credentials['consumer_key']) ) { + $this->oauth = new Puc_v4_OAuthSignature( + $credentials['consumer_key'], + $credentials['consumer_secret'] + ); + } else { + $this->oauth = null; + } + } + + public function signDownloadUrl($url) { + //Add authentication data to download URLs. Since OAuth signatures incorporate + //timestamps, we have to do this immediately before inserting the update. Otherwise + //authentication could fail due to a stale timestamp. + if ( $this->oauth ) { + $url = $this->oauth->sign($url); + } + return $url; + } } endif; \ No newline at end of file diff --git a/Puc/v4/GitHub/Api.php b/Puc/v4/Vcs/GitHubApi.php similarity index 75% rename from Puc/v4/GitHub/Api.php rename to Puc/v4/Vcs/GitHubApi.php index 2dc3558..3e3e74d 100644 --- a/Puc/v4/GitHub/Api.php +++ b/Puc/v4/Vcs/GitHubApi.php @@ -1,8 +1,8 @@ repositoryUrl = $repositoryUrl; - $this->accessToken = $accessToken; - $path = @parse_url($repositoryUrl, PHP_URL_PATH); if ( preg_match('@^/?(?P[^/]+?)/(?P[^/#?&]+?)/?$@', $path, $matches) ) { $this->userName = $matches['username']; @@ -33,12 +30,14 @@ if ( !class_exists('Puc_v4_GitHub_Api', false) ): } else { throw new InvalidArgumentException('Invalid GitHub repository URL: "' . $repositoryUrl . '"'); } + + parent::__construct($repositoryUrl, $accessToken); } /** * Get the latest release from GitHub. * - * @return Puc_v4_VcsReference|null + * @return Puc_v4_Vcs_Reference|null */ public function getLatestRelease() { $release = $this->api('/repos/:user/:repo/releases/latest'); @@ -46,10 +45,10 @@ if ( !class_exists('Puc_v4_GitHub_Api', false) ): return null; } - $reference = new Puc_v4_VcsReference(array( + $reference = new Puc_v4_Vcs_Reference(array( 'name' => $release->tag_name, 'version' => ltrim($release->tag_name, 'v'), //Remove the "v" prefix from "v1.2.3". - 'downloadUrl' => $release->zipball_url, + 'downloadUrl' => $this->signDownloadUrl($release->zipball_url), 'updated' => $release->created_at, )); @@ -67,7 +66,7 @@ if ( !class_exists('Puc_v4_GitHub_Api', false) ): /** * Get the tag that looks like the highest version number. * - * @return Puc_v4_VcsReference|null + * @return Puc_v4_Vcs_Reference|null */ public function getLatestTag() { $tags = $this->api('/repos/:user/:repo/tags'); @@ -76,13 +75,16 @@ if ( !class_exists('Puc_v4_GitHub_Api', false) ): return null; } - usort($tags, array($this, 'compareTagNames')); //Sort from highest to lowest. + $versionTags = $this->sortTagsByVersion($tags); + if ( empty($versionTags) ) { + return null; + } - $tag = $tags[0]; - return new Puc_v4_VcsReference(array( + $tag = $versionTags[0]; + return new Puc_v4_Vcs_Reference(array( 'name' => $tag->name, 'version' => ltrim($tag->name, 'v'), - 'downloadUrl' => $tag->zipball_url, + 'downloadUrl' => $this->signDownloadUrl($tag->zipball_url), )); } @@ -90,7 +92,7 @@ if ( !class_exists('Puc_v4_GitHub_Api', false) ): * Get a branch by name. * * @param string $branchName - * @return null|Puc_v4_VcsReference + * @return null|Puc_v4_Vcs_Reference */ public function getBranch($branchName) { $branch = $this->api('/repos/:user/:repo/branches/' . $branchName); @@ -98,7 +100,7 @@ if ( !class_exists('Puc_v4_GitHub_Api', false) ): return null; } - $reference = new Puc_v4_VcsReference(array( + $reference = new Puc_v4_Vcs_Reference(array( 'name' => $branch->name, 'downloadUrl' => $this->buildArchiveDownloadUrl($branch->name), )); @@ -218,7 +220,7 @@ if ( !class_exists('Puc_v4_GitHub_Api', false) ): urlencode($ref) ); if ( !empty($this->accessToken) ) { - $url = add_query_arg('access_token', $this->accessToken, $url); + $url = $this->signDownloadUrl($url); } return $url; } @@ -227,12 +229,43 @@ if ( !class_exists('Puc_v4_GitHub_Api', false) ): * Get a specific tag. * * @param string $tagName - * @return Puc_v4_VcsReference|null + * @return Puc_v4_Vcs_Reference|null */ public function getTag($tagName) { - //The current GitHub update checker doesn't use getTag, so didn't bother to implement it. + //The current GitHub update checker doesn't use getTag, so I didn't bother to implement it. throw new LogicException('The ' . __METHOD__ . ' method is not implemented and should not be used.'); } + + public function setAuthentication($credentials) { + parent::setAuthentication($credentials); + $this->accessToken = is_string($credentials) ? $credentials : null; + } + + /** + * Figure out which reference (i.e tag or branch) contains the latest version. + * + * @param string $configBranch Start looking in this branch. + * @param bool $useStableTag Ignored. The GitHub client doesn't use the "Stable tag" header. + * @return null|Puc_v4_Vcs_Reference + */ + public function chooseReference($configBranch, $useStableTag = false) { + $updateSource = null; + + if ( $configBranch === 'master' ) { + //Use the latest release. + $updateSource = $this->getLatestRelease(); + if ( $updateSource === null ) { + //Failing that, use the tag with the highest version number. + $updateSource = $this->getLatestTag(); + } + } + //Alternatively, just use the branch itself. + if ( empty($updateSource) ) { + $updateSource = $this->getBranch($configBranch); + } + + return $updateSource; + } } endif; \ No newline at end of file diff --git a/Puc/v4/BitBucket/PluginUpdateChecker.php b/Puc/v4/Vcs/PluginUpdateChecker.php similarity index 68% rename from Puc/v4/BitBucket/PluginUpdateChecker.php rename to Puc/v4/Vcs/PluginUpdateChecker.php index a3dae8c..da2ba5f 100644 --- a/Puc/v4/BitBucket/PluginUpdateChecker.php +++ b/Puc/v4/Vcs/PluginUpdateChecker.php @@ -1,25 +1,38 @@ api = $api; + parent::__construct($api->getRepositoryUrl(), $pluginFile, $slug, $checkPeriod, $optionName, $muPluginFile); + } public function requestInfo($queryArgs = array()) { //We have to make several remote API requests to gather all the necessary info //which can take a while on slow networks. set_time_limit(60); - $api = $this->api = new Puc_v4_BitBucket_Api($this->metadataUrl, $this->credentials); + $api = $this->api; $info = new Puc_v4_Plugin_Info(); $info->filename = $this->pluginFile; @@ -28,12 +41,19 @@ if ( !class_exists('Puc_v4_BitBucket_PluginUpdateChecker') ): $this->setInfoFromHeader($this->getPluginHeader(), $info); //Pick a branch or tag. - $updateSource = $this->chooseReference(); + $updateSource = $api->chooseReference($this->branch); if ( $updateSource ) { $ref = $updateSource->name; $info->version = $updateSource->version; $info->last_updated = $updateSource->updated; $info->download_url = $updateSource->downloadUrl; + + if ( !empty($updateSource->changelog) ) { + $info->sections['changelog'] = $updateSource->changelog; + } + if ( isset($updateSource->downloadCount) ) { + $info->downloaded = $updateSource->downloadCount; + } } else { //There's probably a network problem or an authentication error. return null; @@ -74,56 +94,6 @@ if ( !class_exists('Puc_v4_BitBucket_PluginUpdateChecker') ): return $info; } - /** - * Figure out which reference (tag or branch) we'll use to get the latest version of the plugin. - * - * @return Puc_v4_VcsReference|null - */ - protected function chooseReference() { - $api = $this->api; - $updateSource = null; - - //Check if there's a "Stable tag: v1.2.3" header that points to a valid tag. - $remoteReadme = $api->getRemoteReadme($this->branch); - if ( !empty($remoteReadme['stable_tag']) ) { - $tag = $remoteReadme['stable_tag']; - - //You can explicitly opt out of using tags by setting "Stable tag" to - //"trunk" or the name of the current branch. - if ( ($tag === $this->branch) || ($tag === 'trunk') ) { - return $api->getBranch($this->branch); - } - - $updateSource = $api->getTag($tag); - } - //Look for version-like tags. - if ( !$updateSource && ($this->branch === 'master') ) { - $updateSource = $api->getLatestTag(); - } - //If all else fails, use the specified branch itself. - if ( !$updateSource ) { - $updateSource = $api->getBranch($this->branch); - } - - return $updateSource; - } - - public function setAuthentication($credentials) { - $this->credentials = array_merge( - array( - 'consumer_key' => '', - 'consumer_secret' => '', - ), - $credentials - ); - return $this; - } - - public function setBranch($branchName = 'master') { - $this->branch = $branchName; - return $this; - } - /** * Check if the currently installed version has a readme.txt file. * @@ -195,20 +165,21 @@ if ( !class_exists('Puc_v4_BitBucket_PluginUpdateChecker') ): } } + public function setBranch($branch) { + $this->branch = $branch; + return $this; + } + + public function setAuthentication($credentials) { + $this->api->setAuthentication($credentials); + return $this; + } + public function getUpdate() { $update = parent::getUpdate(); - //Add authentication data to download URLs. Since OAuth signatures incorporate - //timestamps, we have to do this immediately before inserting the update. Otherwise - //authentication could fail due to a stale timestamp. - if ( isset($update, $update->download_url) && !empty($update->download_url) && !empty($this->credentials) ) { - if ( !empty($this->credentials['consumer_key']) ) { - $oauth = new Puc_v4_OAuthSignature( - $this->credentials['consumer_key'], - $this->credentials['consumer_secret'] - ); - $update->download_url = $oauth->sign($update->download_url); - } + if ( isset($update) && !empty($update->download_url) ) { + $update->download_url = $this->api->signDownloadUrl($update->download_url); } return $update; diff --git a/Puc/v4/VcsReference.php b/Puc/v4/Vcs/Reference.php similarity index 91% rename from Puc/v4/VcsReference.php rename to Puc/v4/Vcs/Reference.php index e7047fa..501955f 100644 --- a/Puc/v4/VcsReference.php +++ b/Puc/v4/Vcs/Reference.php @@ -1,5 +1,5 @@ api = $api; + parent::__construct($api->getRepositoryUrl(), $stylesheet, $customSlug, $checkPeriod, $optionName); + } + + public function requestUpdate() { + $api = $this->api; + + $update = new Puc_v4_Theme_Update(); + $update->slug = $this->slug; + + //Figure out which reference (tag or branch) we'll use to get the latest version of the theme. + $updateSource = $api->chooseReference($this->branch, false); + if ( $updateSource ) { + $ref = $updateSource->name; + $update->version = $updateSource->version; + $update->download_url = $updateSource->downloadUrl; + } else { + $ref = $this->branch; + } + + //Get headers from the main stylesheet in this branch/tag. Its "Version" header and other metadata + //are what the WordPress install will actually see after upgrading, so they take precedence over releases/tags. + $remoteStylesheet = $api->getRemoteFile('style.css', $ref); + if ( !empty($remoteStylesheet) ) { + $remoteHeader = $this->getFileHeader($remoteStylesheet); + if ( !empty($remoteHeader['Version']) ) { + $update->version = $remoteHeader['Version']; + } + if ( !empty($remoteHeader['ThemeURI']) ) { + $update->details_url = $remoteHeader['ThemeURI']; + } + } + + //The details URL defaults to the Theme URI header or the repository URL. + if ( empty($update->details_url) ) { + $update->details_url = $this->theme->get('ThemeURI'); + } + if ( empty($update->details_url) ) { + $update->details_url = $this->metadataUrl; + } + + if ( empty($update->version) ) { + //It looks like we didn't find a valid update after all. + $update = null; + } + + $update = $this->filterUpdateResult($update); + return $update; + } + + public function setBranch($branch) { + $this->branch = $branch; + return $this; + } + + public function setAuthentication($credentials) { + $this->api->setAuthentication($credentials); + return $this; + } + + public function getUpdate() { + $update = parent::getUpdate(); + + if ( isset($update) && !empty($update->download_url) ) { + $update->download_url = $this->api->signDownloadUrl($update->download_url); + } + + return $update; + } + + + } + +endif; \ No newline at end of file diff --git a/plugin-update-checker.php b/plugin-update-checker.php index 81dbd87..642809a 100644 --- a/plugin-update-checker.php +++ b/plugin-update-checker.php @@ -13,7 +13,9 @@ new Puc_v4_Autoloader(); //Register classes defined in this file with the factory. Puc_v4_Factory::addVersion('Plugin_UpdateChecker', 'Puc_v4_Plugin_UpdateChecker', '4.0'); Puc_v4_Factory::addVersion('Theme_UpdateChecker', 'Puc_v4_Theme_UpdateChecker', '4.0'); -Puc_v4_Factory::addVersion('GitHub_PluginUpdateChecker', 'Puc_v4_GitHub_PluginUpdateChecker', '4.0'); -Puc_v4_Factory::addVersion('GitHub_ThemeUpdateChecker', 'Puc_v4_GitHub_ThemeUpdateChecker', '4.0'); -Puc_v4_Factory::addVersion('BitBucket_PluginUpdateChecker', 'Puc_v4_BitBucket_PluginUpdateChecker', '4.0'); +Puc_v4_Factory::addVersion('Vcs_PluginUpdateChecker', 'Puc_v4_Vcs_PluginUpdateChecker', '4.0'); +Puc_v4_Factory::addVersion('Vcs_ThemeUpdateChecker', 'Puc_v4_Vcs_ThemeUpdateChecker', '4.0'); + +Puc_v4_Factory::addVersion('GitHubApi', 'Puc_v4_Vcs_GitHubApi', '4.0'); +Puc_v4_Factory::addVersion('BitBucketApi', 'Puc_v4_Vcs_BitBucketApi', '4.0'); From f41dc30d28e63d99970f0f0d96f908e178d4b9b6 Mon Sep 17 00:00:00 2001 From: Yahnis Elsts Date: Wed, 28 Dec 2016 16:09:28 +0200 Subject: [PATCH 19/44] Minor fixes and some comments --- Puc/v4/Metadata.php | 5 +++-- Puc/v4/Plugin/UpdateChecker.php | 15 +++++++++------ Puc/v4/Vcs/PluginUpdateChecker.php | 1 + vendor/readme-parser.php | 1 + 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/Puc/v4/Metadata.php b/Puc/v4/Metadata.php index 7391e70..7bb5082 100644 --- a/Puc/v4/Metadata.php +++ b/Puc/v4/Metadata.php @@ -103,7 +103,7 @@ if ( !class_exists('Puc_v4_Metadata', false) ): if ( property_exists($from, 'slug') && !empty($from->slug) ) { //Let plugins add extra fields without having to create subclasses. - $fields = apply_filters($this->getPrefixedFilter('retain_fields') . $from->slug, $fields); + $fields = apply_filters($this->getPrefixedFilter('retain_fields') . '-' . $from->slug, $fields); } foreach ($fields as $field) { @@ -121,10 +121,11 @@ if ( !class_exists('Puc_v4_Metadata', false) ): } /** + * @param string $tag * @return string */ protected function getPrefixedFilter($tag) { - return 'puc_' . $tag . '-'; + return 'puc_' . $tag; } } diff --git a/Puc/v4/Plugin/UpdateChecker.php b/Puc/v4/Plugin/UpdateChecker.php index 3c085a7..3035a34 100644 --- a/Puc/v4/Plugin/UpdateChecker.php +++ b/Puc/v4/Plugin/UpdateChecker.php @@ -106,16 +106,16 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): //Query args to append to the URL. Plugins can add their own by using a filter callback (see addQueryArgFilter()). $installedVersion = $this->getInstalledVersion(); $queryArgs['installed_version'] = ($installedVersion !== null) ? $installedVersion : ''; - $queryArgs = apply_filters('puc_request_info_query_args-'.$this->slug, $queryArgs); + $queryArgs = apply_filters($this->getUniqueName('request_info_query_args'), $queryArgs); //Various options for the wp_remote_get() call. Plugins can filter these, too. $options = array( 'timeout' => 10, //seconds 'headers' => array( - 'Accept' => 'application/json' + 'Accept' => 'application/json', ), ); - $options = apply_filters('puc_request_info_options-'.$this->slug, $options); + $options = apply_filters($this->getUniqueName('request_info_options'), $options); //The plugin info should be at 'http://your-api.com/url/here/$slug/info.json' $url = $this->metadataUrl; @@ -145,7 +145,7 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): ); } - $pluginInfo = apply_filters('puc_request_info_result-'.$this->slug, $pluginInfo, $result); + $pluginInfo = apply_filters($this->getUniqueName('request_info_result'), $pluginInfo, $result); return $pluginInfo; } @@ -388,7 +388,10 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): 'puc_check_for_updates' ); - $linkText = apply_filters('puc_manual_check_link-' . $this->slug, __('Check for updates', 'plugin-update-checker')); + $linkText = apply_filters( + $this->getUniqueName('manual_check_link'), + __('Check for updates', 'plugin-update-checker') + ); if ( !empty($linkText) ) { /** @noinspection HtmlUnknownTarget */ $pluginMeta[] = sprintf('%s', esc_attr($linkUrl), $linkText); @@ -441,7 +444,7 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): } printf( '

%s

', - apply_filters('puc_manual_check_message-' . $this->slug, $message, $status) + apply_filters($this->getUniqueName('manual_check_message'), $message, $status) ); } } diff --git a/Puc/v4/Vcs/PluginUpdateChecker.php b/Puc/v4/Vcs/PluginUpdateChecker.php index da2ba5f..acf3095 100644 --- a/Puc/v4/Vcs/PluginUpdateChecker.php +++ b/Puc/v4/Vcs/PluginUpdateChecker.php @@ -27,6 +27,7 @@ if ( !class_exists('Puc_v4_Vcs_PluginUpdateChecker') ): parent::__construct($api->getRepositoryUrl(), $pluginFile, $slug, $checkPeriod, $optionName, $muPluginFile); } + //TODO: Do something about this unused parameter. public function requestInfo($queryArgs = array()) { //We have to make several remote API requests to gather all the necessary info //which can take a while on slow networks. diff --git a/vendor/readme-parser.php b/vendor/readme-parser.php index 334bca7..d89a06e 100644 --- a/vendor/readme-parser.php +++ b/vendor/readme-parser.php @@ -240,6 +240,7 @@ class PucReadmeParser { if ( $markdown ) { // Parse markdown. if ( !class_exists('Parsedown', false) ) { + /** @noinspection PhpIncludeInspection */ require_once(dirname(__FILE__) . '/Parsedown' . (version_compare(PHP_VERSION, '5.3.0', '>=') ? '' : 'Legacy') . '.php'); } $instance = Parsedown::instance(); From 3004e10f750b2a1b337869fedebd5eec25c459f2 Mon Sep 17 00:00:00 2001 From: Yahnis Elsts Date: Wed, 28 Dec 2016 17:15:42 +0200 Subject: [PATCH 20/44] Add a way to filter the HTTP options used when making GitHub/BitBucket API requests. Example: $updateChecker->addHttpRequestArgFilter('my_callback'); function my_callback($options) { $options['timeout'] = 30; return $options; } --- Puc/v4/Plugin/UpdateChecker.php | 2 +- Puc/v4/Vcs/Api.php | 13 +++++++++++++ Puc/v4/Vcs/BitBucketApi.php | 7 +++++-- Puc/v4/Vcs/GitHubApi.php | 6 +++++- Puc/v4/Vcs/PluginUpdateChecker.php | 5 +++-- Puc/v4/Vcs/ThemeUpdateChecker.php | 2 ++ 6 files changed, 29 insertions(+), 6 deletions(-) diff --git a/Puc/v4/Plugin/UpdateChecker.php b/Puc/v4/Plugin/UpdateChecker.php index 3035a34..0201bed 100644 --- a/Puc/v4/Plugin/UpdateChecker.php +++ b/Puc/v4/Plugin/UpdateChecker.php @@ -102,7 +102,7 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): * @param array $queryArgs Additional query arguments to append to the request. Optional. * @return Puc_v4_Plugin_Info */ - public function requestInfo($queryArgs = array()){ + public function requestInfo($queryArgs = array()) { //Query args to append to the URL. Plugins can add their own by using a filter callback (see addQueryArgFilter()). $installedVersion = $this->getInstalledVersion(); $queryArgs['installed_version'] = ($installedVersion !== null) ? $installedVersion : ''; diff --git a/Puc/v4/Vcs/Api.php b/Puc/v4/Vcs/Api.php index 7d5dd32..00034f0 100644 --- a/Puc/v4/Vcs/Api.php +++ b/Puc/v4/Vcs/Api.php @@ -14,6 +14,12 @@ if ( !class_exists('Puc_v4_Vcs_Api') ): */ protected $credentials = null; + /** + * @var string The filter tag that's used to filter options passed to wp_remote_get. + * For example, "puc_request_info_options-slug" or "puc_request_update_options_theme-slug". + */ + protected $httpFilterName = ''; + /** * Puc_v4_Vcs_Api constructor. * @@ -222,6 +228,13 @@ if ( !class_exists('Puc_v4_Vcs_Api') ): public function signDownloadUrl($url) { return $url; } + + /** + * @param string $filterName + */ + public function setHttpFilterName($filterName) { + $this->httpFilterName = $filterName; + } } endif; diff --git a/Puc/v4/Vcs/BitBucketApi.php b/Puc/v4/Vcs/BitBucketApi.php index a82139f..2d7f682 100644 --- a/Puc/v4/Vcs/BitBucketApi.php +++ b/Puc/v4/Vcs/BitBucketApi.php @@ -187,8 +187,11 @@ if ( !class_exists('Puc_v4_Vcs_BitBucketApi', false) ): $url = $this->oauth->sign($url,'GET'); } - $response = wp_remote_get($url, array('timeout' => 10)); - //var_dump($response); + $options = array('timeout' => 10); + if ( !empty($this->httpFilterName) ) { + $options = apply_filters($this->httpFilterName, $options); + } + $response = wp_remote_get($url, $options); if ( is_wp_error($response) ) { return $response; } diff --git a/Puc/v4/Vcs/GitHubApi.php b/Puc/v4/Vcs/GitHubApi.php index 3e3e74d..d3168ba 100644 --- a/Puc/v4/Vcs/GitHubApi.php +++ b/Puc/v4/Vcs/GitHubApi.php @@ -171,7 +171,11 @@ if ( !class_exists('Puc_v4_Vcs_GitHubApi', false) ): $url = add_query_arg($queryParams, $url); } - $response = wp_remote_get($url, array('timeout' => 10)); + $options = array('timeout' => 10); + if ( !empty($this->httpFilterName) ) { + $options = apply_filters($this->httpFilterName, $options); + } + $response = wp_remote_get($url, $options); if ( is_wp_error($response) ) { return $response; } diff --git a/Puc/v4/Vcs/PluginUpdateChecker.php b/Puc/v4/Vcs/PluginUpdateChecker.php index acf3095..9e0e46c 100644 --- a/Puc/v4/Vcs/PluginUpdateChecker.php +++ b/Puc/v4/Vcs/PluginUpdateChecker.php @@ -24,11 +24,12 @@ if ( !class_exists('Puc_v4_Vcs_PluginUpdateChecker') ): */ public function __construct($api, $pluginFile, $slug = '', $checkPeriod = 12, $optionName = '', $muPluginFile = '') { $this->api = $api; + $this->api->setHttpFilterName($this->getUniqueName('request_info_options')); + parent::__construct($api->getRepositoryUrl(), $pluginFile, $slug, $checkPeriod, $optionName, $muPluginFile); } - //TODO: Do something about this unused parameter. - public function requestInfo($queryArgs = array()) { + public function requestInfo($unusedParameter = null) { //We have to make several remote API requests to gather all the necessary info //which can take a while on slow networks. set_time_limit(60); diff --git a/Puc/v4/Vcs/ThemeUpdateChecker.php b/Puc/v4/Vcs/ThemeUpdateChecker.php index 5390b3d..f69832c 100644 --- a/Puc/v4/Vcs/ThemeUpdateChecker.php +++ b/Puc/v4/Vcs/ThemeUpdateChecker.php @@ -24,6 +24,8 @@ if ( !class_exists('Puc_v4_Vcs_ThemeUpdateChecker', false) ): */ public function __construct($api, $stylesheet = null, $customSlug = null, $checkPeriod = 12, $optionName = '') { $this->api = $api; + $this->api->setHttpFilterName($this->getUniqueName('request_update_options')); + parent::__construct($api->getRepositoryUrl(), $stylesheet, $customSlug, $checkPeriod, $optionName); } From 35a04faee625b31cf74939e4ffe43c3d7f7fe45d Mon Sep 17 00:00:00 2001 From: Yahnis Elsts Date: Wed, 28 Dec 2016 18:17:11 +0200 Subject: [PATCH 21/44] Apparently BitBucket downloads only work over HTTPS. Lest build the download URL from scratch to ensure that the correct protocol is used. --- Puc/v4/Vcs/BitBucketApi.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Puc/v4/Vcs/BitBucketApi.php b/Puc/v4/Vcs/BitBucketApi.php index 2d7f682..93d843f 100644 --- a/Puc/v4/Vcs/BitBucketApi.php +++ b/Puc/v4/Vcs/BitBucketApi.php @@ -132,7 +132,12 @@ if ( !class_exists('Puc_v4_Vcs_BitBucketApi', false) ): * @return string */ protected function getDownloadUrl($ref) { - return trailingslashit($this->repositoryUrl) . 'get/' . $ref . '.zip'; + return sprintf( + 'https://bitbucket.org/%s/%s/get/%s.zip', + $this->username, + $this->repository, + $ref + ); } /** From 6e31d27187c6a9a7b16c8c482380474572330855 Mon Sep 17 00:00:00 2001 From: Yahnis Elsts Date: Wed, 28 Dec 2016 22:12:32 +0200 Subject: [PATCH 22/44] Minor: Rename and comment. Apparently "latest compatible version" is a more common expression than "greatest compatible version". Also, lets move away from the idea of using the factory class to build anything other than update checker instances; it's probably not necessary. --- Puc/v4/Factory.php | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/Puc/v4/Factory.php b/Puc/v4/Factory.php index 147150e..c1badf5 100644 --- a/Puc/v4/Factory.php +++ b/Puc/v4/Factory.php @@ -2,7 +2,7 @@ if ( !class_exists('Puc_v4_Factory', false) ): /** - * A factory that builds instances of other classes from this library. + * A factory that builds update checker instances. * * When multiple versions of the same class have been loaded (e.g. PluginUpdateChecker 4.0 * and 4.1), this factory will always use the latest available minor version. Register class @@ -16,12 +16,15 @@ if ( !class_exists('Puc_v4_Factory', false) ): protected static $sorted = false; protected static $myMajorVersion = ''; - protected static $greatestCompatVersion = ''; + protected static $latestCompatibleVersion = ''; /** * Create a new instance of the update checker. * - * @see PluginUpdateChecker::__construct() + * This method automatically detects if you're using it for a plugin or a theme and chooses + * the appropriate implementation for your update source (JSON file, GitHub, BitBucket, etc). + * + * @see Puc_v4_UpdateChecker::__construct * * @param string $metadataUrl The URL of the metadata file, a GitHub repository, or another supported update source. * @param string $fullPath Full path to the main plugin file or to the theme directory. @@ -79,12 +82,12 @@ if ( !class_exists('Puc_v4_Factory', false) ): $apiClass = $service . 'Api'; } - $checkerClass = self::getCompatibleClass($checkerClass); + $checkerClass = self::getCompatibleClassVersion($checkerClass); if ( !$checkerClass ) { trigger_error( sprintf( 'PUC %s does not support updates for %ss %s', - htmlentities(self::$greatestCompatVersion), + htmlentities(self::$latestCompatibleVersion), strtolower($type), $service ? ('hosted on ' . htmlentities($service)) : 'using JSON metadata' ), @@ -98,11 +101,11 @@ if ( !class_exists('Puc_v4_Factory', false) ): return new $checkerClass($metadataUrl, $id, $slug, $checkPeriod, $optionName, $muPluginFile); } else { //VCS checker + an API client. - $apiClass = self::getCompatibleClass($apiClass); + $apiClass = self::getCompatibleClassVersion($apiClass); if ( !$apiClass ) { trigger_error(sprintf( 'PUC %s does not support %s', - htmlentities(self::$greatestCompatVersion), + htmlentities(self::$latestCompatibleVersion), htmlentities($service) ), E_USER_ERROR); return null; @@ -140,9 +143,9 @@ if ( !class_exists('Puc_v4_Factory', false) ): * @param string $class Partial class name. * @return string|null Full class name. */ - protected static function getCompatibleClass($class) { - if ( isset(self::$classVersions[$class][self::$greatestCompatVersion]) ) { - return self::$classVersions[$class][self::$greatestCompatVersion]; + protected static function getCompatibleClassVersion($class) { + if ( isset(self::$classVersions[$class][self::$latestCompatibleVersion]) ) { + return self::$classVersions[$class][self::$latestCompatibleVersion]; } return null; } @@ -198,9 +201,14 @@ if ( !class_exists('Puc_v4_Factory', false) ): //Store the greatest version number that matches our major version. $components = explode('.', $version); if ( $components[0] === self::$myMajorVersion ) { - if ( empty(self::$greatestCompatVersion) || version_compare($version, self::$greatestCompatVersion, '>') ) { - self::$greatestCompatVersion = $version; + + if ( + empty(self::$latestCompatibleVersion) + || version_compare($version, self::$latestCompatibleVersion, '>') + ) { + self::$latestCompatibleVersion = $version; } + } if ( !isset(self::$classVersions[$generalClass]) ) { From 592453cffb753cda626f04ad83a4339d95ea0aca Mon Sep 17 00:00:00 2001 From: Yahnis Elsts Date: Fri, 30 Dec 2016 13:28:13 +0200 Subject: [PATCH 23/44] Update description and add JSON examples --- README.md | 211 +++++++++++++++++++++++++++++++------------ composer.json | 4 +- examples/plugin.json | 47 ++++++++++ examples/theme.json | 5 + 4 files changed, 208 insertions(+), 59 deletions(-) create mode 100644 examples/plugin.json create mode 100644 examples/theme.json diff --git a/README.md b/README.md index 9532a9a..e298bf4 100644 --- a/README.md +++ b/README.md @@ -1,93 +1,133 @@ Plugin Update Checker ===================== -This is a custom update checker library for WordPress plugins. It lets you add automatic update notifications and one-click upgrades to your commercial and private plugins. All you need to do is put your plugin details in a JSON file, place the file on your server, and pass the URL to the library. The library periodically checks the URL to see if there's a new version available and displays an update notification to the user if necessary. +This is a custom update checker library for WordPress plugins and themes. It lets you add automatic update notifications and one-click upgrades to your commercial plugins, private themes, and so on. All you need to do is put your plugin/theme details in a JSON file, place the file on your server, and pass the URL to the library. The library periodically checks the URL to see if there's a new version available and displays an update notification to the user if necessary. -From the users' perspective, it works just like with plugins hosted on WordPress.org. The update checker uses the default plugin upgrade UI that will already be familiar to most WordPress users. +From the users' perspective, it works just like with plugins and themes hosted on WordPress.org. The update checker uses the default upgrade UI that is familiar to most WordPress users. -[See this blog post](http://w-shadow.com/blog/2010/09/02/automatic-updates-for-any-plugin/) for more information and usage instructions. +[See this blog post](http://w-shadow.com/blog/2010/09/02/automatic-updates-for-any-plugin/) for more information. Getting Started --------------- -### Self-hosted Plugins +### Self-hosted Plugins and Themes -1. Make a JSON file that describes your plugin. Here's a minimal example: +1. Download [the latest release](https://github.com/YahnisElsts/plugin-update-checker/releases/latest) and copy the `plugin-update-checker` directory to your plugin or theme. +2. Go to the `examples` subdirectory and open the .json file that fits your project type. Replace the placeholder data with your plugin/theme details. + - Plugin example: + ```json + { + "name" : "Plugin Name", + "version" : "2.0", + "download_url" : "http://example.com/plugin-name-2.0.zip", + "sections" : { + "description" : "Plugin description here. You can use HTML." + } + } + ``` + This is a minimal example that leaves out optional fields. See [this table](https://spreadsheets.google.com/pub?key=0AqP80E74YcUWdEdETXZLcXhjd2w0cHMwX2U1eDlWTHc&authkey=CK7h9toK&hl=en&single=true&gid=0&output=html) for a full list of supported fields and their descriptions. + - Theme example: + ```json + { + "version": "2.0", + "details_url": "http://example.com/version-2.0-details.html", + "download_url": "http://example.com/example-theme-2.0.zip" + } + ``` + This is a complete example that shows all theme-related fields. `version` and `download_url` should be self-explanatory. The `details_url` key specifies the page that the user will see if they click the "View version 1.2.3 details" link in an update notification. +3. Upload the JSON file to a publicly accessible location. +4. Add the following code to the main plugin file or to the `functions.php` file: - ```json - { - "name" : "My Cool Plugin", - "version" : "2.0", - "author" : "John Smith", - "download_url" : "http://example.com/plugins/my-cool-plugin.zip", - "sections" : { - "description" : "Plugin description here. You can use HTML." - } - } + ```php + require 'path/to/plugin-update-checker/plugin-update-checker.php'; + $myUpdateChecker = PucFactory::buildUpdateChecker( + 'http://example.com/path/to/details.json', + __FILE__, + 'unique-plugin-or-theme-slug' + ); ``` - See [this table](https://spreadsheets.google.com/pub?key=0AqP80E74YcUWdEdETXZLcXhjd2w0cHMwX2U1eDlWTHc&authkey=CK7h9toK&hl=en&single=true&gid=0&output=html) for a full list of supported fields. -2. Upload this file to a publicly accessible location. -3. Download [the update checker](https://github.com/YahnisElsts/plugin-update-checker/releases/latest), unzip the archive and copy the `plugin-update-checker` directory to your plugin. -4. Add the following code to the main plugin file: + +#### How to Release an Update + +Change the `version` number in the JSON file and make sure that `download_url` points to the latest version. Update the other fields if necessary. Tip: You can use [wp-update-server](https://github.com/YahnisElsts/wp-update-server) to automate this process. + +By default, the library will check the specified URL for changes every 12 hours. You can force it to check immediately by clicking the "Check Now" link on the "Plugins" page (it's next to the "Visit plugin site" link). Themes don't get a "check now" link, but you can also trigger an update check like this: + + 1. Install [Debug Bar](https://srd.wordpress.org/plugins/debug-bar/). + 2. Click the "Debug" menu in the Admin Bar (a.k.a Toolbar). + 3. Open the "PUC (your-slug)" panel. + 4. Click the "Check Now" button. + +#### Notes +- The second argument passed to `buildUpdateChecker` must be the absolute path to the main plugin file or any file in the theme directory. If you followed the "getting started" instructions, you can just use the `__FILE__` constant. +- The third argument - i.e. the slug - is optional but recommended. If it's omitted, the update checker will use the name of the main plugin file as the slug (e.g. `my-cool-plugin.php` → `my-cool-plugin`). This can lead to conflicts if your plugin has a generic file name like `plugin.php`. + + This doesn't affect themes as much because PUC uses the theme directory name as the default slug. Still, if you're planning to use the slug in your own code - e.g. to filter updates or override update checker behaviour - it can be a good idea to set it explicitly. + +### Plugins and Themes Hosted on GitHub + +1. Download [the latest release](https://github.com/YahnisElsts/plugin-update-checker/releases/latest) and copy the `plugin-update-checker` directory to your plugin or theme. +2. Add the following code to the main plugin file or `functions.php`: ```php require 'plugin-update-checker/plugin-update-checker.php'; $myUpdateChecker = PucFactory::buildUpdateChecker( - 'http://example.com/path/to/metadata.json', + 'https://github.com/user-name/repo-name/', __FILE__, - 'unique-plugin-slug' + 'unique-plugin-or-theme-slug' ); + + //Optional: If you're using a private repository, specify the access token like this: + $myUpdateChecker->setAuthentication('your-token-here'); + + //Optional: Set the branch that contains the stable release. + $myUpdateChecker->setBranch('stable-branch-name'); ``` +3. Plugins only: Add a `readme.txt` file formatted according to the [WordPress.org plugin readme standard](https://wordpress.org/plugins/about/readme.txt) to your repository. The contents of this file will be shown when the user clicks the "View version 1.2.3 details" link. -#### Notes -- You can use [wp-update-server](https://github.com/YahnisElsts/wp-update-server) to automatically generate JSON metadata from ZIP packages. -- The second argument passed to `buildUpdateChecker` should be the full path to the main plugin file. -- The `$slug` argument is optional but recommended. If it's omitted, the update checker will use the name of the main plugin file as the slug (e.g. `my-cool-plugin.php` → `my-cool-plugin`). This can lead to conflicts if your plugin has a generic file name like `plugin.php`. -- There are more options available - see the [blog](http://w-shadow.com/blog/2010/09/02/automatic-updates-for-any-plugin/) for details. +#### How to Release an Update -### Plugins Hosted on GitHub +This library supports a couple of different ways to release updates on GitHub. Pick the one that best fits your workflow. -*(GitHub support is experimental.)* - -1. Download [the latest release](https://github.com/YahnisElsts/plugin-update-checker/releases/latest), unzip it and copy the `plugin-update-checker` directory to your plugin. -2. Add the following code to the main file of your plugin: - - ```php - require 'plugin-update-checker/plugin-update-checker.php'; - $className = PucFactory::getLatestClassVersion('PucGitHubChecker'); - $myUpdateChecker = new $className( - 'https://github.com/user-name/plugin-repo-name/', - __FILE__, - 'master' - ); - ``` - The third argument specifies the branch to use for updating your plugin. The default is `master`. If the branch name is omitted or set to `master`, the update checker will use the latest release or tag (if available). Otherwise it will use the specified branch. -3. Optional: Add a `readme.txt` file formatted according to the [WordPress.org plugin readme standard](https://wordpress.org/plugins/about/readme.txt). The contents of this file will be shown when the user clicks the "View version 1.2.3 details" link. +- **GitHub releases** + + Create a new release using the "Releases" feature on GitHub. The tag name and release title don't matter. The description is optional, but if you do provide one, it will be displayed when the user clicks the "View version x.y.z details" link on the "Plugins" page. Note that PUC ignores releases marked as "This is a pre-release". + +- **Tags** + + To release version 1.2.3, create a new Git tag named `v1.2.3` or `1.2.3`. That's it. + + PUC doesn't require strict adherence to [SemVer](http://semver.org/). These are all valid tag names: `v1.2.3`, `v1.2-foo`, `1.2.3_rc1-ABC`, `1.2.3.4.5`. However, be warned that it's not smart enough to filter out alpha/beta/RC versions. If that's a problem, you might want to use GitHub releases or branches instead. + +- **Stable branch** + + Point the update checker at a stable, production-ready branch: + ```php + $updateChecker->setBranch('branch-name'); + ``` + PUC will periodically check the `Version` header in the main plugin file or `style.css` and display a notification if it's greater than the installed version. + + Caveat: If you set the branch to `master` (the default), the update checker will look for recent releases and tags first. It'll only use the `master` branch if it doesn't find anything else suitable. #### Notes -If your GitHub repository requires an access token, you can specify it like this: -```php -$myUpdateChecker->setAccessToken('your-token-here'); -``` - -The GitHub version of the library will pull update details from the following parts of a release/tag/branch: +The library will pull update details from the following parts of a release/tag/branch: +- Version number + - The "Version" plugin header. + - The latest GitHub release or tag name. - Changelog - The "Changelog" section of `readme.txt`. - One of the following files: CHANGES.md, CHANGELOG.md, changes.md, changelog.md - - Release notes. -- Version number - - The "Version" plugin header. - - The latest release or tag name. + - GitHub release notes. - Required and tested WordPress versions - The "Requires at least" and "Tested up to" fields in `readme.txt`. - The following plugin headers: `Required WP`, `Tested WP`, `Requires at least`, `Tested up to` - "Last updated" timestamp - - The creation timestamp of the latest release. - - The latest commit of the selected tag or branch that changed the main plugin file. + - The creation timestamp of the latest GitHub release. + - The latest commit in the selected tag or branch. - Number of downloads - The `download_count` statistic of the latest release. - If you're not using GitHub releases, there will be no download stats. @@ -97,11 +137,68 @@ The GitHub version of the library will pull update details from the following pa - Local plugin headers (i.e. the currently installed version). - Ratings, banners, screenshots - Not supported. + +### Plugins and Themes Hosted on BitBucket + +1. Download [the latest release](https://github.com/YahnisElsts/plugin-update-checker/releases/latest) and copy the `plugin-update-checker` directory to your plugin or theme. +2. Add the following code to the main plugin file or `functions.php`: + + ```php + require 'plugin-update-checker/plugin-update-checker.php'; + $myUpdateChecker = PucFactory::buildUpdateChecker( + 'https://bitbucket.org/user-name/repo-name', + __FILE__, + 'unique-plugin-or-theme-slug' + ); + + //Optional: If you're using a private repository, create an OAuth consumer + //and set the authentication credentials like this: + $myUpdateChecker->setAuthentication(array( + 'consumer_key' => '...', + 'consumer_secret' => '...', + )); + + //Optional: Set the branch that contains the stable release. + $myUpdateChecker->setBranch('stable-branch-name'); + ``` +3. Plugins only: Add a `readme.txt` file formatted according to the [WordPress.org plugin readme standard](https://wordpress.org/plugins/about/readme.txt) to your repository. The contents of this file will be shown when the user clicks the "View version 1.2.3 details" link. + +#### How to Release an Update + +BitBucket doesn't have an equivalent to GitHubs "releases" feature, so the process is slightly different. You can use any of the following approaches: + +- **`Stable tag` header** + + This is the recommended approach if you're using tags to mark each version. Add a `readme.txt` file formatted according to the [WordPress.org plugin readme standard](https://wordpress.org/plugins/about/readme.txt) to your repository. Set the "stable tag" header to the tag that represents the latest release. Example: + ```text + Stable tag: v1.2.3 + ``` + The tag doesn't have to start with a "v" or follow any particular format. You can use any name you like as long as it's a valid Git tag. + + Tip: If you explicitly set a stable branch, the update checker will look for a `readme.txt` in that branch. Otherwise it will only look at the `master` branch. + +- **Tags** + + You can skip the "stable tag" bit and just create a new Git tag named `v1.2.3` or `1.2.3`. The update checker will look at the most recent tags and pick the one that looks like the highest version number. + + PUC doesn't require strict adherence to [SemVer](http://semver.org/). These are all valid tag names: `v1.2.3`, `v1.2-foo`, `1.2.3_rc1-ABC`, `1.2.3.4.5`. However, be warned that it's not smart enough to filter out alpha/beta/RC versions. + +- **Stable branch** + + Point the update checker at a stable, production-ready branch: + ```php + $updateChecker->setBranch('branch-name'); + ``` + PUC will periodically check the `Version` header in the main plugin file or `style.css` and display a notification if it's greater than the installed version. Caveat: If you set the branch to `master`, the update checker will still look for tags first. + +#### Notes + +The "Stable tag" header also works for themes as long as you include a `readme.txt` formatted according to plugin standards. Resources --------- -- [Theme Update Checker](http://w-shadow.com/blog/2011/06/02/automatic-updates-for-commercial-themes/) - [Debug Bar](https://wordpress.org/plugins/debug-bar/) - useful for testing and debugging the update checker. - [Securing download links](http://w-shadow.com/blog/2013/03/19/plugin-updates-securing-download-links/) - a general overview. -- [A GUI for entering download credentials](http://open-tools.net/documentation/tutorial-automatic-updates.html#wordpress) \ No newline at end of file +- [A GUI for entering download credentials](http://open-tools.net/documentation/tutorial-automatic-updates.html#wordpress) +- [Theme Update Checker](http://w-shadow.com/blog/2011/06/02/automatic-updates-for-commercial-themes/) - an older, theme-only variant of this update checker. diff --git a/composer.json b/composer.json index 8d1d4d3..22fc59b 100644 --- a/composer.json +++ b/composer.json @@ -1,8 +1,8 @@ { "name": "yahnis-elsts/plugin-update-checker", "type": "library", - "description": "A custom update checker for WordPress plugins. Useful if you can't host your plugin in the official WP plugin repository but still want it to support automatic plugin updates.", - "keywords": ["wordpress", "plugin updates", "automatic updates"], + "description": "A custom update checker for WordPress plugins and themes. Useful if you can't host your plugin in the official WP repository but still want it to support automatic updates.", + "keywords": ["wordpress", "plugin updates", "automatic updates", "theme updates"], "homepage": "https://github.com/YahnisElsts/plugin-update-checker/", "license": "MIT", "authors": [ diff --git a/examples/plugin.json b/examples/plugin.json new file mode 100644 index 0000000..685b943 --- /dev/null +++ b/examples/plugin.json @@ -0,0 +1,47 @@ +{ + "name": "My Example Plugin", + "version": "2.0", + "download_url": "http://example.com/updates/example-plugin.zip", + + "homepage": "http://example.com/", + "requires": "4.5", + "tested": "4.8", + "last_updated": "2017-01-01 16:17:00", + "upgrade_notice": "Here's why you should upgrade...", + + "author": "Janis Elsts", + "author_homepage": "http://example.com/", + + "sections": { + "description": "(Required) Plugin description. Basic HTML can be used in all sections.", + "installation": "(Recommended) Installation instructions.", + "changelog": "(Recommended) Changelog.

This section will be displayed by default when the user clicks 'View version x.y.z details'.

", + "custom_section": "This is a custom section labeled 'Custom Section'." + }, + + "banners": { + "low": "http://w-shadow.com/files/external-update-example/assets/banner-772x250.png", + "high": "http://w-shadow.com/files/external-update-example/assets/banner-1544x500.png" + }, + + "translations": [ + { + "language": "fr_FR", + "version": "4.0", + "updated": "2016-04-22 23:22:42", + "package": "http://example.com/updates/translations/french-language-pack.zip" + }, + { + "language": "de_DE", + "version": "5.0", + "updated": "2016-04-22 23:22:42", + "package": "http://example.com/updates/translations/german-language-pack.zip" + } + ], + + "rating": 90, + "num_ratings": 123, + + "downloaded": 1234, + "active_installs": 12345 +} \ No newline at end of file diff --git a/examples/theme.json b/examples/theme.json new file mode 100644 index 0000000..df6c8c7 --- /dev/null +++ b/examples/theme.json @@ -0,0 +1,5 @@ +{ + "version": "2.0", + "details_url": "http://example.com/version-2.0-details.html", + "download_url": "http://example.com/example-theme-2.0.zip" +} \ No newline at end of file From 635ca656049309d6aa53d24289547ba0a0045e2b Mon Sep 17 00:00:00 2001 From: Yahnis Elsts Date: Fri, 30 Dec 2016 15:51:11 +0200 Subject: [PATCH 24/44] Add table of contents and rename some headings --- README.md | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index e298bf4..7c4fb23 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,23 @@ From the users' perspective, it works just like with plugins and themes hosted o [See this blog post](http://w-shadow.com/blog/2010/09/02/automatic-updates-for-any-plugin/) for more information. + + +**Table of Contents** + +- [Getting Started](#getting-started) + - [Self-hosted Plugins and Themes](#self-hosted-plugins-and-themes) + - [How to Release an Update](#how-to-release-an-update) + - [Notes](#notes) + - [GitHub Integration](#github-integration) + - [How to Release an Update](#how-to-release-an-update-1) + - [Notes](#notes-1) + - [BitBucket Integration](#bitbucket-integration) + - [How to Release an Update](#how-to-release-an-update-2) +- [Resources](#resources) + + + Getting Started --------------- @@ -64,7 +81,7 @@ By default, the library will check the specified URL for changes every 12 hours. This doesn't affect themes as much because PUC uses the theme directory name as the default slug. Still, if you're planning to use the slug in your own code - e.g. to filter updates or override update checker behaviour - it can be a good idea to set it explicitly. -### Plugins and Themes Hosted on GitHub +### GitHub Integration 1. Download [the latest release](https://github.com/YahnisElsts/plugin-update-checker/releases/latest) and copy the `plugin-update-checker` directory to your plugin or theme. 2. Add the following code to the main plugin file or `functions.php`: @@ -138,7 +155,7 @@ The library will pull update details from the following parts of a release/tag/b - Ratings, banners, screenshots - Not supported. -### Plugins and Themes Hosted on BitBucket +### BitBucket Integration 1. Download [the latest release](https://github.com/YahnisElsts/plugin-update-checker/releases/latest) and copy the `plugin-update-checker` directory to your plugin or theme. 2. Add the following code to the main plugin file or `functions.php`: @@ -165,7 +182,7 @@ The library will pull update details from the following parts of a release/tag/b #### How to Release an Update -BitBucket doesn't have an equivalent to GitHubs "releases" feature, so the process is slightly different. You can use any of the following approaches: +BitBucket doesn't have an equivalent to GitHub's releases, so the process is slightly different. You can use any of the following approaches: - **`Stable tag` header** @@ -190,10 +207,6 @@ BitBucket doesn't have an equivalent to GitHubs "releases" feature, so the proce $updateChecker->setBranch('branch-name'); ``` PUC will periodically check the `Version` header in the main plugin file or `style.css` and display a notification if it's greater than the installed version. Caveat: If you set the branch to `master`, the update checker will still look for tags first. - -#### Notes - -The "Stable tag" header also works for themes as long as you include a `readme.txt` formatted according to plugin standards. Resources --------- From fd78fc9250451426600fd151a313855b49368109 Mon Sep 17 00:00:00 2001 From: Yahnis Elsts Date: Fri, 30 Dec 2016 15:53:13 +0200 Subject: [PATCH 25/44] Fix spacing of code examples --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 7c4fb23..2dae361 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ Getting Started 1. Download [the latest release](https://github.com/YahnisElsts/plugin-update-checker/releases/latest) and copy the `plugin-update-checker` directory to your plugin or theme. 2. Go to the `examples` subdirectory and open the .json file that fits your project type. Replace the placeholder data with your plugin/theme details. - Plugin example: + ```json { "name" : "Plugin Name", @@ -42,8 +43,10 @@ Getting Started } } ``` + This is a minimal example that leaves out optional fields. See [this table](https://spreadsheets.google.com/pub?key=0AqP80E74YcUWdEdETXZLcXhjd2w0cHMwX2U1eDlWTHc&authkey=CK7h9toK&hl=en&single=true&gid=0&output=html) for a full list of supported fields and their descriptions. - Theme example: + ```json { "version": "2.0", @@ -51,6 +54,7 @@ Getting Started "download_url": "http://example.com/example-theme-2.0.zip" } ``` + This is a complete example that shows all theme-related fields. `version` and `download_url` should be self-explanatory. The `details_url` key specifies the page that the user will see if they click the "View version 1.2.3 details" link in an update notification. 3. Upload the JSON file to a publicly accessible location. 4. Add the following code to the main plugin file or to the `functions.php` file: From 8e5bc8c3216fdadefcdddaf853578ca9ace583ef Mon Sep 17 00:00:00 2001 From: Yahnis Elsts Date: Fri, 30 Dec 2016 19:29:28 +0200 Subject: [PATCH 26/44] Extract translation filter as a method. --- Puc/v4/UpdateChecker.php | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/Puc/v4/UpdateChecker.php b/Puc/v4/UpdateChecker.php index c009737..3ef16b1 100644 --- a/Puc/v4/UpdateChecker.php +++ b/Puc/v4/UpdateChecker.php @@ -535,17 +535,10 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): //In case there's a name collision with a plugin or theme hosted on wordpress.org, //remove any preexisting updates that match our thing. - $filteredTranslations = array(); - foreach($updates->translations as $translation) { - if ( - ($translation['type'] === $this->translationType) - && ($translation['slug'] === $this->directoryName) - ) { - continue; - } - $filteredTranslations[] = $translation; - } - $updates->translations = $filteredTranslations; + $updates->translations = array_values(array_filter( + $updates->translations, + array($this, 'isNotMyTranslation') + )); //Add our updates to the list. foreach($translationUpdates as $update) { @@ -596,6 +589,20 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): } } + /** + * Filter callback. Keeps only translations that *don't* match this plugin or theme. + * + * @param array $translation + * @return bool + */ + protected function isNotMyTranslation($translation) { + $isMatch = isset($translation['type'], $translation['slug']) + && ($translation['type'] === $this->translationType) + && ($translation['slug'] === $this->directoryName); + + return !$isMatch; + } + /* ------------------------------------------------------------------- * Fix directory name when installing updates * ------------------------------------------------------------------- From 36ef731e4f35e2f0486125344abc307808fbeec2 Mon Sep 17 00:00:00 2001 From: Yahnis Elsts Date: Fri, 30 Dec 2016 19:44:00 +0200 Subject: [PATCH 27/44] Remove redundant if condition. is_object(null) is false, so we don't need a separate empty($state) condition. --- Puc/v4/UpdateChecker.php | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Puc/v4/UpdateChecker.php b/Puc/v4/UpdateChecker.php index 3ef16b1..0ce6e98 100644 --- a/Puc/v4/UpdateChecker.php +++ b/Puc/v4/UpdateChecker.php @@ -203,11 +203,9 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): */ public function getUpdateState() { $state = get_site_option($this->optionName, null); - if ( empty($state) || !is_object($state) ) { + if ( !is_object($state) ) { $state = null; - } - - if ( isset($state, $state->update) && is_object($state->update) ) { + } else if ( isset($state->update) && is_object($state->update) ) { $state->update = call_user_func(array($this->updateClass, 'fromObject'), $state->update); } return $state; From 0e67e4c588c7157691ad803d74e93fefffa2d5f0 Mon Sep 17 00:00:00 2001 From: Yahnis Elsts Date: Fri, 30 Dec 2016 21:08:50 +0200 Subject: [PATCH 28/44] Extract VCS service detection as a method --- Puc/v4/Factory.php | 47 ++++++++++++++++++++++++++++------------------ 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/Puc/v4/Factory.php b/Puc/v4/Factory.php index c1badf5..d0a8484 100644 --- a/Puc/v4/Factory.php +++ b/Puc/v4/Factory.php @@ -36,7 +36,6 @@ if ( !class_exists('Puc_v4_Factory', false) ): */ public static function buildUpdateChecker($metadataUrl, $fullPath, $slug = '', $checkPeriod = 12, $optionName = '', $muPluginFile = '') { $fullPath = wp_normalize_path($fullPath); - $service = null; $id = null; //Plugin or theme? @@ -53,23 +52,7 @@ if ( !class_exists('Puc_v4_Factory', false) ): } //Which hosting service does the URL point to? - $host = @parse_url($metadataUrl, PHP_URL_HOST); - $path = @parse_url($metadataUrl, PHP_URL_PATH); - //Check if the path looks like "/user-name/repository". - $usernameRepoRegex = '@^/?([^/]+?)/([^/#?&]+?)/?$@'; - if ( preg_match($usernameRepoRegex, $path) ) { - switch($host) { - case 'github.com': - $service = 'GitHub'; - break; - case 'bitbucket.org': - $service = 'BitBucket'; - break; - case 'gitlab.com': - $service = 'GitLab'; - break; - } - } + $service = self::getVcsService($metadataUrl); $checkerClass = null; $apiClass = null; @@ -136,6 +119,34 @@ if ( !class_exists('Puc_v4_Factory', false) ): return (strpos($absolutePath, $pluginDir) === 0) || (strpos($absolutePath, $muPluginDir) === 0); } + /** + * Get the name of the hosting service that the URL points to. + * + * @param string $metadataUrl + * @return string|null + */ + private static function getVcsService($metadataUrl) { + $service = null; + + //Which hosting service does the URL point to? + $host = @parse_url($metadataUrl, PHP_URL_HOST); + $path = @parse_url($metadataUrl, PHP_URL_PATH); + //Check if the path looks like "/user-name/repository". + $usernameRepoRegex = '@^/?([^/]+?)/([^/#?&]+?)/?$@'; + if ( preg_match($usernameRepoRegex, $path) ) { + $knownServices = array( + 'github.com' => 'GitHub', + 'bitbucket.org' => 'BitBucket', + 'gitlab.com' => 'GitLab', + ); + if ( isset($knownServices[$host]) ) { + $service = $knownServices[$host]; + } + } + + return $service; + } + /** * Get the latest version of the specified class that has the same major version number * as this factory class. From 870901b1f221673120448c8f3763b0523b9b64e1 Mon Sep 17 00:00:00 2001 From: Yahnis Elsts Date: Sat, 31 Dec 2016 13:46:10 +0200 Subject: [PATCH 29/44] Let themes hosted on BitBucket use the "Stable tag" header to specify the latest version. --- Puc/v4/Vcs/ThemeUpdateChecker.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Puc/v4/Vcs/ThemeUpdateChecker.php b/Puc/v4/Vcs/ThemeUpdateChecker.php index f69832c..085c81f 100644 --- a/Puc/v4/Vcs/ThemeUpdateChecker.php +++ b/Puc/v4/Vcs/ThemeUpdateChecker.php @@ -36,7 +36,7 @@ if ( !class_exists('Puc_v4_Vcs_ThemeUpdateChecker', false) ): $update->slug = $this->slug; //Figure out which reference (tag or branch) we'll use to get the latest version of the theme. - $updateSource = $api->chooseReference($this->branch, false); + $updateSource = $api->chooseReference($this->branch); if ( $updateSource ) { $ref = $updateSource->name; $update->version = $updateSource->version; From e9b377e9992b57f6577002e3557ba643e0d5b20d Mon Sep 17 00:00:00 2001 From: Yahnis Elsts Date: Sat, 31 Dec 2016 14:12:17 +0200 Subject: [PATCH 30/44] Simplify VCS-based theme checker. --- Puc/v4/UpdateChecker.php | 4 +- Puc/v4/Utils.php | 65 +++++++++++++++++++++++++++++++ Puc/v4/Vcs/ThemeUpdateChecker.php | 27 +++++-------- 3 files changed, 78 insertions(+), 18 deletions(-) create mode 100644 Puc/v4/Utils.php diff --git a/Puc/v4/UpdateChecker.php b/Puc/v4/UpdateChecker.php index 0ce6e98..8d4533b 100644 --- a/Puc/v4/UpdateChecker.php +++ b/Puc/v4/UpdateChecker.php @@ -722,10 +722,12 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): * This is basically a simplified version of the get_file_data() function from /wp-includes/functions.php. * It's intended as a utility for subclasses that detect updates by parsing files in a VCS. * - * @param string $content File contents. + * @param string|null $content File contents. * @return string[] */ public function getFileHeader($content) { + $content = (string) $content; + //WordPress only looks at the first 8 KiB of the file, so we do the same. $content = substr($content, 0, 8192); //Normalize line endings. diff --git a/Puc/v4/Utils.php b/Puc/v4/Utils.php new file mode 100644 index 0000000..94bdaa7 --- /dev/null +++ b/Puc/v4/Utils.php @@ -0,0 +1,65 @@ +$node; + } else { + $pathExists = false; + break; + } + } + + if ( $pathExists ) { + return $currentValue; + } + return $default; + } + + /** + * Get the first array element that is not empty. + * + * @param array $values + * @param mixed|null $default Returns this value if there are no non-empty elements. + * @return mixed|null + */ + public static function findNotEmpty($values, $default = null) { + if ( empty($values) ) { + return $default; + } + + foreach ($values as $value) { + if ( !empty($value) ) { + return $value; + } + } + + return $default; + } + } + +endif; \ No newline at end of file diff --git a/Puc/v4/Vcs/ThemeUpdateChecker.php b/Puc/v4/Vcs/ThemeUpdateChecker.php index 085c81f..3b70ec9 100644 --- a/Puc/v4/Vcs/ThemeUpdateChecker.php +++ b/Puc/v4/Vcs/ThemeUpdateChecker.php @@ -39,7 +39,6 @@ if ( !class_exists('Puc_v4_Vcs_ThemeUpdateChecker', false) ): $updateSource = $api->chooseReference($this->branch); if ( $updateSource ) { $ref = $updateSource->name; - $update->version = $updateSource->version; $update->download_url = $updateSource->downloadUrl; } else { $ref = $this->branch; @@ -47,24 +46,18 @@ if ( !class_exists('Puc_v4_Vcs_ThemeUpdateChecker', false) ): //Get headers from the main stylesheet in this branch/tag. Its "Version" header and other metadata //are what the WordPress install will actually see after upgrading, so they take precedence over releases/tags. - $remoteStylesheet = $api->getRemoteFile('style.css', $ref); - if ( !empty($remoteStylesheet) ) { - $remoteHeader = $this->getFileHeader($remoteStylesheet); - if ( !empty($remoteHeader['Version']) ) { - $update->version = $remoteHeader['Version']; - } - if ( !empty($remoteHeader['ThemeURI']) ) { - $update->details_url = $remoteHeader['ThemeURI']; - } - } + $remoteHeader = $this->getFileHeader($api->getRemoteFile('style.css', $ref)); + $update->version = Puc_v4_Utils::findNotEmpty(array( + $remoteHeader['Version'], + Puc_v4_Utils::get($updateSource, 'version'), + )); //The details URL defaults to the Theme URI header or the repository URL. - if ( empty($update->details_url) ) { - $update->details_url = $this->theme->get('ThemeURI'); - } - if ( empty($update->details_url) ) { - $update->details_url = $this->metadataUrl; - } + $update->details_url = Puc_v4_Utils::findNotEmpty(array( + $remoteHeader['ThemeURI'], + $this->theme->get('ThemeURI'), + $this->metadataUrl, + )); if ( empty($update->version) ) { //It looks like we didn't find a valid update after all. From 13ca09ed1c1d50c7fd8c95e8365dad7044ece2f8 Mon Sep 17 00:00:00 2001 From: Yahnis Elsts Date: Sat, 31 Dec 2016 14:17:24 +0200 Subject: [PATCH 31/44] Explicitly allow null input to Utils::get(). It just returns $default. --- Puc/v4/Utils.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Puc/v4/Utils.php b/Puc/v4/Utils.php index 94bdaa7..d4e6693 100644 --- a/Puc/v4/Utils.php +++ b/Puc/v4/Utils.php @@ -6,7 +6,7 @@ if ( !class_exists('Puc_v4_Utils', false) ): /** * Get a value from a nested array or object based on a path. * - * @param array|object $array Get an entry from this array. + * @param array|object|null $array Get an entry from this array. * @param array|string $path A list of array keys in hierarchy order, or a string path like "foo.bar.baz". * @param mixed $default The value to return if the specified path is not found. * @param string $separator Path element separator. Only applies to string paths. From b1fce3c2f4563f4392f623d48b46e20014f5b353 Mon Sep 17 00:00:00 2001 From: Yahnis Elsts Date: Sat, 31 Dec 2016 14:19:10 +0200 Subject: [PATCH 32/44] Minor: Update PHPDoc to show that $update can be null (i.e. no update available). --- Puc/v4/UpdateChecker.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Puc/v4/UpdateChecker.php b/Puc/v4/UpdateChecker.php index 8d4533b..65794b5 100644 --- a/Puc/v4/UpdateChecker.php +++ b/Puc/v4/UpdateChecker.php @@ -274,7 +274,7 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): /** * Filter the result of a requestUpdate() call. * - * @param Puc_v4_Update $update + * @param Puc_v4_Update|null $update * @param array|WP_Error|null $httpResult The value returned by wp_remote_get(), if any. * @return Puc_v4_Update */ From b74182cb47f81325caf84941953a0f4e8ed92eb8 Mon Sep 17 00:00:00 2001 From: Yahnis Elsts Date: Sat, 31 Dec 2016 21:10:48 +0200 Subject: [PATCH 33/44] Minor: Simplify maybeCheckForUpdates. We don't need to check if $state is empty; isset() suffices. --- Puc/v4/Scheduler.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Puc/v4/Scheduler.php b/Puc/v4/Scheduler.php index f8332d1..bb30b25 100644 --- a/Puc/v4/Scheduler.php +++ b/Puc/v4/Scheduler.php @@ -96,15 +96,14 @@ if ( !class_exists('Puc_v4_Scheduler', false) ): $state = $this->updateChecker->getUpdateState(); $shouldCheck = - empty($state) || - !isset($state->lastCheck) || + !isset($state, $state->lastCheck) || ( (time() - $state->lastCheck) >= $this->getEffectiveCheckPeriod() ); //Let plugin authors substitute their own algorithm. $shouldCheck = apply_filters( $this->updateChecker->getUniqueName('check_now'), $shouldCheck, - (!empty($state) && isset($state->lastCheck)) ? $state->lastCheck : 0, + Puc_v4_Utils::get($state, 'lastCheck', 0), $this->checkPeriod ); From 06eba37b276fef517ad043e4a5c55a7bc3153ad0 Mon Sep 17 00:00:00 2001 From: Yahnis Elsts Date: Sun, 1 Jan 2017 14:25:18 +0200 Subject: [PATCH 34/44] Remove unused parameter. Move stable tag detection to a method. Make null-comparison explicit in the factory. --- Puc/v4/Factory.php | 5 ++--- Puc/v4/Vcs/Api.php | 3 +-- Puc/v4/Vcs/BitBucketApi.php | 41 ++++++++++++++++++++++--------------- Puc/v4/Vcs/GitHubApi.php | 3 +-- 4 files changed, 29 insertions(+), 23 deletions(-) diff --git a/Puc/v4/Factory.php b/Puc/v4/Factory.php index d0a8484..86e2526 100644 --- a/Puc/v4/Factory.php +++ b/Puc/v4/Factory.php @@ -54,7 +54,6 @@ if ( !class_exists('Puc_v4_Factory', false) ): //Which hosting service does the URL point to? $service = self::getVcsService($metadataUrl); - $checkerClass = null; $apiClass = null; if ( empty($service) ) { //The default is to get update information from a remote JSON file. @@ -66,7 +65,7 @@ if ( !class_exists('Puc_v4_Factory', false) ): } $checkerClass = self::getCompatibleClassVersion($checkerClass); - if ( !$checkerClass ) { + if ( $checkerClass === null ) { trigger_error( sprintf( 'PUC %s does not support updates for %ss %s', @@ -85,7 +84,7 @@ if ( !class_exists('Puc_v4_Factory', false) ): } else { //VCS checker + an API client. $apiClass = self::getCompatibleClassVersion($apiClass); - if ( !$apiClass ) { + if ( $apiClass === null ) { trigger_error(sprintf( 'PUC %s does not support %s', htmlentities(self::$latestCompatibleVersion), diff --git a/Puc/v4/Vcs/Api.php b/Puc/v4/Vcs/Api.php index 00034f0..f7dfbea 100644 --- a/Puc/v4/Vcs/Api.php +++ b/Puc/v4/Vcs/Api.php @@ -42,10 +42,9 @@ if ( !class_exists('Puc_v4_Vcs_Api') ): * Figure out which reference (i.e tag or branch) contains the latest version. * * @param string $configBranch Start looking in this branch. - * @param bool $useStableTag * @return null|Puc_v4_Vcs_Reference */ - abstract public function chooseReference($configBranch, $useStableTag = true); + abstract public function chooseReference($configBranch); /** * Get the readme.txt file from the remote repository and parse it diff --git a/Puc/v4/Vcs/BitBucketApi.php b/Puc/v4/Vcs/BitBucketApi.php index 93d843f..7632612 100644 --- a/Puc/v4/Vcs/BitBucketApi.php +++ b/Puc/v4/Vcs/BitBucketApi.php @@ -33,27 +33,13 @@ if ( !class_exists('Puc_v4_Vcs_BitBucketApi', false) ): * Figure out which reference (i.e tag or branch) contains the latest version. * * @param string $configBranch Start looking in this branch. - * @param bool $useStableTag * @return null|Puc_v4_Vcs_Reference */ - public function chooseReference($configBranch, $useStableTag = true) { + public function chooseReference($configBranch) { $updateSource = null; //Check if there's a "Stable tag: 1.2.3" header that points to a valid tag. - if ( $useStableTag ) { - $remoteReadme = $this->getRemoteReadme($configBranch); - if ( !empty($remoteReadme['stable_tag']) ) { - $tag = $remoteReadme['stable_tag']; - - //You can explicitly opt out of using tags by setting "Stable tag" to - //"trunk" or the name of the current branch. - if ( ($tag === $configBranch) || ($tag === 'trunk') ) { - return $this->getBranch($configBranch); - } - - $updateSource = $this->getTag($tag); - } - } + $updateSource = $this->getStableTag($configBranch); //Look for version-like tags. if ( !$updateSource && ($configBranch === 'master') ) { @@ -127,6 +113,29 @@ if ( !class_exists('Puc_v4_Vcs_BitBucketApi', false) ): return null; } + /** + * Get the tag/ref specified by the "Stable tag" header in the readme.txt of a given branch. + * + * @param string $branch + * @return null|Puc_v4_Vcs_Reference + */ + protected function getStableTag($branch) { + $remoteReadme = $this->getRemoteReadme($branch); + if ( !empty($remoteReadme['stable_tag']) ) { + $tag = $remoteReadme['stable_tag']; + + //You can explicitly opt out of using tags by setting "Stable tag" to + //"trunk" or the name of the current branch. + if ( ($tag === $branch) || ($tag === 'trunk') ) { + return $this->getBranch($branch); + } + + return $this->getTag($tag); + } + + return null; + } + /** * @param string $ref * @return string diff --git a/Puc/v4/Vcs/GitHubApi.php b/Puc/v4/Vcs/GitHubApi.php index d3168ba..07cfcd2 100644 --- a/Puc/v4/Vcs/GitHubApi.php +++ b/Puc/v4/Vcs/GitHubApi.php @@ -249,10 +249,9 @@ if ( !class_exists('Puc_v4_Vcs_GitHubApi', false) ): * Figure out which reference (i.e tag or branch) contains the latest version. * * @param string $configBranch Start looking in this branch. - * @param bool $useStableTag Ignored. The GitHub client doesn't use the "Stable tag" header. * @return null|Puc_v4_Vcs_Reference */ - public function chooseReference($configBranch, $useStableTag = false) { + public function chooseReference($configBranch) { $updateSource = null; if ( $configBranch === 'master' ) { From 1e709c61992c8c6d12bd34b6e66d739799187aff Mon Sep 17 00:00:00 2001 From: Yahnis Elsts Date: Tue, 3 Jan 2017 13:22:38 +0200 Subject: [PATCH 35/44] Add branch name and authentication state (on/off) to the Debug Bar panel. There's some code duplication here. Unpleasant. --- Puc/v4/DebugBar/Panel.php | 5 ++++- Puc/v4/DebugBar/PluginPanel.php | 1 + Puc/v4/DebugBar/ThemePanel.php | 1 + Puc/v4/UpdateChecker.php | 9 +++++++++ Puc/v4/Vcs/Api.php | 4 ++++ Puc/v4/Vcs/PluginUpdateChecker.php | 5 +++++ Puc/v4/Vcs/ThemeUpdateChecker.php | 8 +++++++- 7 files changed, 31 insertions(+), 2 deletions(-) diff --git a/Puc/v4/DebugBar/Panel.php b/Puc/v4/DebugBar/Panel.php index 19fa39d..ce4307d 100644 --- a/Puc/v4/DebugBar/Panel.php +++ b/Puc/v4/DebugBar/Panel.php @@ -65,6 +65,9 @@ if ( !class_exists('Puc_v4_DebugBar_Panel', false) && class_exists('Debug_Bar_Pa $this->row('Throttling', 'Disabled'); } } + + $this->updateChecker->onDisplayConfiguration($this); + echo ''; } @@ -149,7 +152,7 @@ if ( !class_exists('Puc_v4_DebugBar_Panel', false) && class_exists('Debug_Bar_Pa return gmdate('Y-m-d H:i:s', $unixTime + (get_option('gmt_offset') * 3600)); } - protected function row($name, $value) { + public function row($name, $value) { if ( is_object($value) || is_array($value) ) { $value = '
' . htmlentities(print_r($value, true)) . '
'; } else if ($value === null) { diff --git a/Puc/v4/DebugBar/PluginPanel.php b/Puc/v4/DebugBar/PluginPanel.php index 74d5283..5017dba 100644 --- a/Puc/v4/DebugBar/PluginPanel.php +++ b/Puc/v4/DebugBar/PluginPanel.php @@ -10,6 +10,7 @@ if ( !class_exists('Puc_v4_DebugBar_PluginPanel', false) ): protected function displayConfigHeader() { $this->row('Plugin file', htmlentities($this->updateChecker->pluginFile)); + parent::displayConfigHeader(); } protected function getMetadataButton() { diff --git a/Puc/v4/DebugBar/ThemePanel.php b/Puc/v4/DebugBar/ThemePanel.php index 647ac76..eefec56 100644 --- a/Puc/v4/DebugBar/ThemePanel.php +++ b/Puc/v4/DebugBar/ThemePanel.php @@ -10,6 +10,7 @@ if ( !class_exists('Puc_v4_DebugBar_ThemePanel', false) ): protected function displayConfigHeader() { $this->row('Theme directory', htmlentities($this->updateChecker->directoryName)); + parent::displayConfigHeader(); } protected function getUpdateFields() { diff --git a/Puc/v4/UpdateChecker.php b/Puc/v4/UpdateChecker.php index 65794b5..0b92203 100644 --- a/Puc/v4/UpdateChecker.php +++ b/Puc/v4/UpdateChecker.php @@ -775,6 +775,15 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): return new Puc_v4_DebugBar_Extension($this); } + /** + * Display additional configuration details in the Debug Bar panel. + * + * @param Puc_v4_DebugBar_Panel $panel + */ + public function onDisplayConfiguration($panel) { + //Do nothing. Subclasses can use this to add additional info to the panel. + } + } endif; \ No newline at end of file diff --git a/Puc/v4/Vcs/Api.php b/Puc/v4/Vcs/Api.php index f7dfbea..f049344 100644 --- a/Puc/v4/Vcs/Api.php +++ b/Puc/v4/Vcs/Api.php @@ -220,6 +220,10 @@ if ( !class_exists('Puc_v4_Vcs_Api') ): $this->credentials = $credentials; } + public function isAuthenticationEnabled() { + return !empty($this->credentials); + } + /** * @param string $url * @return string diff --git a/Puc/v4/Vcs/PluginUpdateChecker.php b/Puc/v4/Vcs/PluginUpdateChecker.php index 9e0e46c..29c0207 100644 --- a/Puc/v4/Vcs/PluginUpdateChecker.php +++ b/Puc/v4/Vcs/PluginUpdateChecker.php @@ -187,6 +187,11 @@ if ( !class_exists('Puc_v4_Vcs_PluginUpdateChecker') ): return $update; } + public function onDisplayConfiguration($panel) { + parent::onDisplayConfiguration($panel); + $panel->row('Branch', $this->branch); + $panel->row('Authentication enabled', $this->api->isAuthenticationEnabled() ? 'Yes' : 'No'); + } } endif; \ No newline at end of file diff --git a/Puc/v4/Vcs/ThemeUpdateChecker.php b/Puc/v4/Vcs/ThemeUpdateChecker.php index 3b70ec9..0e40d9b 100644 --- a/Puc/v4/Vcs/ThemeUpdateChecker.php +++ b/Puc/v4/Vcs/ThemeUpdateChecker.php @@ -68,6 +68,8 @@ if ( !class_exists('Puc_v4_Vcs_ThemeUpdateChecker', false) ): return $update; } + //FIXME: This is duplicated code. Both theme and plugin subclasses that use VCS share these methods. + public function setBranch($branch) { $this->branch = $branch; return $this; @@ -88,7 +90,11 @@ if ( !class_exists('Puc_v4_Vcs_ThemeUpdateChecker', false) ): return $update; } - + public function onDisplayConfiguration($panel) { + parent::onDisplayConfiguration($panel); + $panel->row('Branch', $this->branch); + $panel->row('Authentication enabled', $this->api->isAuthenticationEnabled() ? 'Yes' : 'No'); + } } endif; \ No newline at end of file From 1905ed84dd11bcc1c443a7f59bc5184cae074098 Mon Sep 17 00:00:00 2001 From: Yahnis Elsts Date: Tue, 3 Jan 2017 13:37:43 +0200 Subject: [PATCH 36/44] Add API client class name to the debug panel. --- Puc/v4/Vcs/PluginUpdateChecker.php | 1 + Puc/v4/Vcs/ThemeUpdateChecker.php | 1 + 2 files changed, 2 insertions(+) diff --git a/Puc/v4/Vcs/PluginUpdateChecker.php b/Puc/v4/Vcs/PluginUpdateChecker.php index 29c0207..68232d1 100644 --- a/Puc/v4/Vcs/PluginUpdateChecker.php +++ b/Puc/v4/Vcs/PluginUpdateChecker.php @@ -191,6 +191,7 @@ if ( !class_exists('Puc_v4_Vcs_PluginUpdateChecker') ): parent::onDisplayConfiguration($panel); $panel->row('Branch', $this->branch); $panel->row('Authentication enabled', $this->api->isAuthenticationEnabled() ? 'Yes' : 'No'); + $panel->row('API client', get_class($this->api)); } } diff --git a/Puc/v4/Vcs/ThemeUpdateChecker.php b/Puc/v4/Vcs/ThemeUpdateChecker.php index 0e40d9b..1f54651 100644 --- a/Puc/v4/Vcs/ThemeUpdateChecker.php +++ b/Puc/v4/Vcs/ThemeUpdateChecker.php @@ -94,6 +94,7 @@ if ( !class_exists('Puc_v4_Vcs_ThemeUpdateChecker', false) ): parent::onDisplayConfiguration($panel); $panel->row('Branch', $this->branch); $panel->row('Authentication enabled', $this->api->isAuthenticationEnabled() ? 'Yes' : 'No'); + $panel->row('API client', get_class($this->api)); } } From aeeda3c330116133e2e32268a082e29118b88653 Mon Sep 17 00:00:00 2001 From: Yahnis Elsts Date: Fri, 6 Jan 2017 10:40:42 +0200 Subject: [PATCH 37/44] Reduce code duplication by moving most similar code from requestUpdate and requestInfo into a new method: requestMetadata. --- Puc/v4/Plugin/UpdateChecker.php | 44 ++---------- Puc/v4/Theme/UpdateChecker.php | 44 ++---------- Puc/v4/UpdateChecker.php | 116 +++++++++++++++++++++++--------- 3 files changed, 93 insertions(+), 111 deletions(-) diff --git a/Puc/v4/Plugin/UpdateChecker.php b/Puc/v4/Plugin/UpdateChecker.php index 0201bed..9330986 100644 --- a/Puc/v4/Plugin/UpdateChecker.php +++ b/Puc/v4/Plugin/UpdateChecker.php @@ -103,46 +103,12 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): * @return Puc_v4_Plugin_Info */ public function requestInfo($queryArgs = array()) { - //Query args to append to the URL. Plugins can add their own by using a filter callback (see addQueryArgFilter()). - $installedVersion = $this->getInstalledVersion(); - $queryArgs['installed_version'] = ($installedVersion !== null) ? $installedVersion : ''; - $queryArgs = apply_filters($this->getUniqueName('request_info_query_args'), $queryArgs); + list($pluginInfo, $result) = $this->requestMetadata('Puc_v4_Plugin_Info', 'request_info', $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->getUniqueName('request_info_options'), $options); - - //The plugin info should be at 'http://your-api.com/url/here/$slug/info.json' - $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); - $pluginInfo = null; - if ( !is_wp_error($status) ){ - $pluginInfo = Puc_v4_Plugin_Info::fromJson($result['body']); - if ( $pluginInfo !== null ) { - $pluginInfo->filename = $this->pluginFile; - $pluginInfo->slug = $this->slug; - } - } else { - $this->triggerError( - sprintf('The URL %s does not point to a valid plugin metadata file. ', $url) - . $status->get_error_message(), - E_USER_WARNING - ); + if ( $pluginInfo !== null ) { + /** @var Puc_v4_Plugin_Info $pluginInfo */ + $pluginInfo->filename = $this->pluginFile; + $pluginInfo->slug = $this->slug; } $pluginInfo = apply_filters($this->getUniqueName('request_info_result'), $pluginInfo, $result); diff --git a/Puc/v4/Theme/UpdateChecker.php b/Puc/v4/Theme/UpdateChecker.php index a3c1ccf..6b23b71 100644 --- a/Puc/v4/Theme/UpdateChecker.php +++ b/Puc/v4/Theme/UpdateChecker.php @@ -34,10 +34,6 @@ if ( !class_exists('Puc_v4_Theme_UpdateChecker', false) ): ); } - protected function installHooks() { - parent::installHooks(); - } - /** * For themes, the update array is indexed by theme directory name. * @@ -53,43 +49,11 @@ if ( !class_exists('Puc_v4_Theme_UpdateChecker', false) ): * @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 : ''; + list($themeUpdate, $result) = $this->requestMetadata('Puc_v4_Theme_Update', 'request_update'); - $queryArgs = apply_filters($this->getUniqueName('request_update_query_args'), $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->getUniqueName('request_update_options'), $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 - ); + if ( $themeUpdate !== null ) { + /** @var Puc_v4_Theme_Update $themeUpdate */ + $themeUpdate->slug = $this->slug; } $themeUpdate = $this->filterUpdateResult($themeUpdate, $result); diff --git a/Puc/v4/UpdateChecker.php b/Puc/v4/UpdateChecker.php index 0b92203..e4ba2ae 100644 --- a/Puc/v4/UpdateChecker.php +++ b/Puc/v4/UpdateChecker.php @@ -290,38 +290,6 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): return $update; } - /** - * 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( - 'puc_no_response_code', - 'wp_remote_get() returned an unexpected result.' - ); - } - - if ( $result['response']['code'] !== 200 ) { - return new WP_Error( - 'puc_unexpected_response_code', - 'HTTP response code is ' . $result['response']['code'] . ' (expected: 200)' - ); - } - - if ( empty($result['body']) ) { - return new WP_Error('puc_empty_response', 'The metadata file appears to be empty.'); - } - - return true; - } - /** * Get the currently installed version of the plugin or theme. * @@ -461,6 +429,90 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): return true; } + /* ------------------------------------------------------------------- + * JSON-based update API + * ------------------------------------------------------------------- + */ + + /** + * Retrieve plugin or theme metadata from the JSON document at $this->metadataUrl. + * + * @param string $metaClass Parse the JSON as an instance of this class. It must have a static fromJson method. + * @param string $filterRoot + * @param array $queryArgs Additional query arguments. + * @return array [Puc_v4_Metadata|null, array|WP_Error] A metadata instance and the value returned by wp_remote_get(). + */ + protected function requestMetadata($metaClass, $filterRoot, $queryArgs = array()) { + //Query args to append to the URL. Plugins can add their own by using a filter callback (see addQueryArgFilter()). + $installedVersion = $this->getInstalledVersion(); + $queryArgs['installed_version'] = ($installedVersion !== null) ? $installedVersion : ''; + $queryArgs = apply_filters($this->getUniqueName($filterRoot . '_query_args'), $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->getUniqueName($filterRoot . '_options'), $options); + + //The metadata file should be at 'http://your-api.com/url/here/$slug/info.json' + $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); + $metadata = null; + if ( !is_wp_error($status) ){ + $metadata = call_user_func(array($metaClass, 'fromJson'), $result['body']); + } else { + $this->triggerError( + sprintf('The URL %s does not point to a valid metadata file. ', $url) + . $status->get_error_message(), + E_USER_WARNING + ); + } + + return array($metadata, $result); + } + + /** + * 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( + 'puc_no_response_code', + 'wp_remote_get() returned an unexpected result.' + ); + } + + if ( $result['response']['code'] !== 200 ) { + return new WP_Error( + 'puc_unexpected_response_code', + 'HTTP response code is ' . $result['response']['code'] . ' (expected: 200)' + ); + } + + if ( empty($result['body']) ) { + return new WP_Error('puc_empty_response', 'The metadata file appears to be empty.'); + } + + return true; + } + /* ------------------------------------------------------------------- * Language packs / Translation updates * ------------------------------------------------------------------- From 844516d1f5db555865f1bb7c9836f836392d17ee Mon Sep 17 00:00:00 2001 From: Yahnis Elsts Date: Tue, 10 Jan 2017 13:13:01 +0200 Subject: [PATCH 38/44] Treat null entries as non-existent. Add a startsWith() method. This is necessary to avoid fatal errors when trying to retrieve existing but inaccessible properties (i.e. private or protected). --- Puc/v4/Utils.php | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/Puc/v4/Utils.php b/Puc/v4/Utils.php index d4e6693..c36fb83 100644 --- a/Puc/v4/Utils.php +++ b/Puc/v4/Utils.php @@ -24,9 +24,9 @@ if ( !class_exists('Puc_v4_Utils', false) ): $currentValue = $array; $pathExists = true; foreach ($path as $node) { - if ( is_array($currentValue) && array_key_exists($node, $currentValue) ) { + if ( is_array($currentValue) && isset($currentValue[$node]) ) { $currentValue = $currentValue[$node]; - } else if ( is_object($currentValue) && property_exists($currentValue, $node) ) { + } else if ( is_object($currentValue) && isset($currentValue->$node) ) { $currentValue = $currentValue->$node; } else { $pathExists = false; @@ -60,6 +60,18 @@ if ( !class_exists('Puc_v4_Utils', false) ): return $default; } + + /** + * Check if the input string starts with the specified prefix. + * + * @param string $input + * @param string $prefix + * @return bool + */ + public static function startsWith($input, $prefix) { + $length = strlen($prefix); + return (substr($input, 0, $length) === $prefix); + } } endif; \ No newline at end of file From 57df68d407e2a4338cb728489ba16637a3d13257 Mon Sep 17 00:00:00 2001 From: Yahnis Elsts Date: Tue, 10 Jan 2017 15:25:36 +0200 Subject: [PATCH 39/44] Extract state storage as a new class. Lazy-loading is a bit messy. Could it be improved with magic __get() and __set()? --- Puc/v4/DebugBar/Panel.php | 10 +- Puc/v4/Plugin/UpdateChecker.php | 2 +- Puc/v4/Scheduler.php | 6 +- Puc/v4/StateStore.php | 207 ++++++++++++++++++++++++++++++++ Puc/v4/Theme/UpdateChecker.php | 2 +- Puc/v4/UpdateChecker.php | 82 ++++--------- 6 files changed, 241 insertions(+), 68 deletions(-) create mode 100644 Puc/v4/StateStore.php diff --git a/Puc/v4/DebugBar/Panel.php b/Puc/v4/DebugBar/Panel.php index ce4307d..944f7b3 100644 --- a/Puc/v4/DebugBar/Panel.php +++ b/Puc/v4/DebugBar/Panel.php @@ -94,8 +94,8 @@ if ( !class_exists('Puc_v4_DebugBar_Panel', false) && class_exists('Debug_Bar_Pa ); } - if ( isset($state, $state->lastCheck) ) { - $this->row('Last check', $this->formatTimeWithDelta($state->lastCheck) . ' ' . $checkNowButton . $this->responseBox); + if ( $state->getLastCheck() > 0 ) { + $this->row('Last check', $this->formatTimeWithDelta($state->getLastCheck()) . ' ' . $checkNowButton . $this->responseBox); } else { $this->row('Last check', 'Never'); } @@ -103,9 +103,9 @@ if ( !class_exists('Puc_v4_DebugBar_Panel', false) && class_exists('Debug_Bar_Pa $nextCheck = wp_next_scheduled($this->updateChecker->scheduler->getCronHookName()); $this->row('Next automatic check', $this->formatTimeWithDelta($nextCheck)); - if ( isset($state, $state->checkedVersion) ) { - $this->row('Checked version', htmlentities($state->checkedVersion)); - $this->row('Cached update', $state->update); + if ( $state->getCheckedVersion() !== '' ) { + $this->row('Checked version', htmlentities($state->getCheckedVersion())); + $this->row('Cached update', $state->getUpdate()); } $this->row('Update checker class', htmlentities(get_class($this->updateChecker))); echo ''; diff --git a/Puc/v4/Plugin/UpdateChecker.php b/Puc/v4/Plugin/UpdateChecker.php index 9330986..b56b86a 100644 --- a/Puc/v4/Plugin/UpdateChecker.php +++ b/Puc/v4/Plugin/UpdateChecker.php @@ -120,7 +120,7 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): * * @uses PluginUpdateChecker::requestInfo() * - * @return Puc_v4_Update An instance of Plugin_Update, or NULL when no updates are available. + * @return Puc_v4_Update|null An instance of Plugin_Update, or NULL when no updates are available. */ public function requestUpdate() { //For the sake of simplicity, this function just calls requestInfo() diff --git a/Puc/v4/Scheduler.php b/Puc/v4/Scheduler.php index bb30b25..1c1c781 100644 --- a/Puc/v4/Scheduler.php +++ b/Puc/v4/Scheduler.php @@ -95,15 +95,13 @@ if ( !class_exists('Puc_v4_Scheduler', false) ): } $state = $this->updateChecker->getUpdateState(); - $shouldCheck = - !isset($state, $state->lastCheck) || - ( (time() - $state->lastCheck) >= $this->getEffectiveCheckPeriod() ); + $shouldCheck = ($state->timeSinceLastCheck() >= $this->getEffectiveCheckPeriod()); //Let plugin authors substitute their own algorithm. $shouldCheck = apply_filters( $this->updateChecker->getUniqueName('check_now'), $shouldCheck, - Puc_v4_Utils::get($state, 'lastCheck', 0), + $state->getLastCheck(), $this->checkPeriod ); diff --git a/Puc/v4/StateStore.php b/Puc/v4/StateStore.php new file mode 100644 index 0000000..a5d7821 --- /dev/null +++ b/Puc/v4/StateStore.php @@ -0,0 +1,207 @@ +optionName = $optionName; + } + + /** + * Get time elapsed since the last update check. + * + * If there are no recorded update checks, this method returns a large arbitrary number + * (i.e. time since the Unix epoch). + * + * @return int Elapsed time in seconds. + */ + public function timeSinceLastCheck() { + $this->lazyLoad(); + return time() - $this->lastCheck; + } + + /** + * @return int + */ + public function getLastCheck() { + $this->lazyLoad(); + return $this->lastCheck; + } + + /** + * Set the time of the last update check to the current timestamp. + * + * @return $this + */ + public function setLastCheckToNow() { + $this->lazyLoad(); + $this->lastCheck = time(); + return $this; + } + + /** + * @return null|Puc_v4_Update + */ + public function getUpdate() { + $this->lazyLoad(); + return $this->update; + } + + /** + * @param Puc_v4_Update|null $update + * @return $this + */ + public function setUpdate(Puc_v4_Update $update = null) { + $this->lazyLoad(); + $this->update = $update; + return $this; + } + + /** + * @return string + */ + public function getCheckedVersion() { + $this->lazyLoad(); + return $this->checkedVersion; + } + + /** + * @param string $version + * @return $this + */ + public function setCheckedVersion($version) { + $this->lazyLoad(); + $this->checkedVersion = strval($version); + return $this; + } + + /** + * Get translation updates. + * + * @return array + */ + public function getTranslations() { + $this->lazyLoad(); + if ( isset($this->update, $this->update->translations) ) { + return $this->update->translations; + } + return array(); + } + + /** + * Set translation updates. + * + * @param array $translationUpdates + */ + public function setTranslations($translationUpdates) { + $this->lazyLoad(); + if ( isset($this->update) ) { + $this->update->translations = $translationUpdates; + $this->save(); + } + } + + public function save() { + $state = new stdClass(); + + $state->lastCheck = $this->lastCheck; + $state->checkedVersion = $this->checkedVersion; + + if ( isset($this->update)) { + $state->update = $this->update->toStdClass(); + + $updateClass = get_class($this->update); + $state->updateClass = $updateClass; + $prefix = $this->getLibPrefix(); + if ( Puc_v4_Utils::startsWith($updateClass, $prefix) ) { + $state->updateBaseClass = substr($updateClass, strlen($prefix)); + } + } + + update_site_option($this->optionName, $state); + $this->isLoaded = true; + } + + /** + * @return $this + */ + public function lazyLoad() { + if ( !$this->isLoaded ) { + $this->load(); + } + return $this; + } + + protected function load() { + $this->isLoaded = true; + + $state = get_site_option($this->optionName, null); + + if ( !is_object($state) ) { + $this->lastCheck = 0; + $this->checkedVersion = ''; + $this->update = null; + return; + } + + $this->lastCheck = intval(Puc_v4_Utils::get($state, 'lastCheck', 0)); + $this->checkedVersion = Puc_v4_Utils::get($state, 'checkedVersion', ''); + $this->update = null; + + if ( isset($state->update) ) { + //This mess is due to the fact that the want the update class from this version + //of the library, not the version that saved the update. + + $updateClass = null; + if ( isset($state->updateBaseClass) ) { + $updateClass = $this->getLibPrefix() . $state->updateBaseClass; + } else if ( isset($state->updateClass) && class_exists($state->updateClass) ) { + $updateClass = $state->updateClass; + } + + if ( $updateClass !== null ) { + $this->update = call_user_func(array($updateClass, 'fromObject'), $state->update); + } + } + } + + public function delete() { + delete_site_option($this->optionName); + + $this->lastCheck = 0; + $this->checkedVersion = ''; + $this->update = null; + } + + private function getLibPrefix() { + $parts = explode('_', __CLASS__, 3); + return $parts[0] . '_' . $parts[1] . '_'; + } + } + +endif; diff --git a/Puc/v4/Theme/UpdateChecker.php b/Puc/v4/Theme/UpdateChecker.php index 6b23b71..56d0448 100644 --- a/Puc/v4/Theme/UpdateChecker.php +++ b/Puc/v4/Theme/UpdateChecker.php @@ -46,7 +46,7 @@ if ( !class_exists('Puc_v4_Theme_UpdateChecker', false) ): /** * 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. + * @return Puc_v4_Update|null An instance of Update, or NULL when no updates are available. */ public function requestUpdate() { list($themeUpdate, $result) = $this->requestMetadata('Puc_v4_Theme_Update', 'request_update'); diff --git a/Puc/v4/UpdateChecker.php b/Puc/v4/UpdateChecker.php index e4ba2ae..f60313b 100644 --- a/Puc/v4/UpdateChecker.php +++ b/Puc/v4/UpdateChecker.php @@ -41,16 +41,16 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): */ public $scheduler; - /** - * @var string The host component of $metadataUrl. - */ - protected $metadataHost = ''; - /** * @var Puc_v4_UpgraderStatus */ protected $upgraderStatus; + /** + * @var Puc_v4_StateStore + */ + protected $updateState; + public function __construct($metadataUrl, $directoryName, $slug = null, $checkPeriod = 12, $optionName = '') { $this->debugMode = (bool)(constant('WP_DEBUG')); $this->metadataUrl = $metadataUrl; @@ -70,6 +70,7 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): $this->scheduler = $this->createScheduler($checkPeriod); $this->upgraderStatus = new Puc_v4_UpgraderStatus(); + $this->updateState = new Puc_v4_StateStore($this->optionName); $this->loadTextDomain(); $this->installHooks(); @@ -108,7 +109,6 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): add_filter('upgrader_source_selection', array($this, 'fixDirectoryName'), 10, 3); //Allow HTTP requests to the metadata URL even if it's on a local host. - $this->metadataHost = @parse_url($this->metadataUrl, PHP_URL_HOST); add_filter('http_request_host_is_external', array($this, 'allowMetadataHost'), 10, 2); //DebugBar integration. @@ -145,7 +145,12 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): * @return bool */ public function allowMetadataHost($allow, $host) { - if ( strtolower($host) === strtolower($this->metadataHost) ) { + static $metadataHost = 0; //Using 0 instead of NULL because parse_url can return NULL. + if ( $metadataHost === 0 ) { + $metadataHost = @parse_url($this->metadataUrl, PHP_URL_HOST); + } + + if ( is_string($metadataHost) && (strtolower($host) === strtolower($metadataHost)) ) { return true; } return $allow; @@ -178,20 +183,13 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): return null; } - $state = $this->getUpdateState(); - if ( empty($state) ) { - $state = new stdClass; - $state->lastCheck = 0; - $state->checkedVersion = ''; - $state->update = null; - } + $state = $this->updateState; + $state->setLastCheckToNow() + ->setCheckedVersion($installedVersion) + ->save(); //Save before checking in case something goes wrong - $state->lastCheck = time(); - $state->checkedVersion = $installedVersion; - $this->setUpdateState($state); //Save before checking in case something goes wrong - - $state->update = $this->requestUpdate(); - $this->setUpdateState($state); + $state->setUpdate($this->requestUpdate()); + $state->save(); return $this->getUpdate(); } @@ -199,31 +197,10 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): /** * Load the update checker state from the DB. * - * @return stdClass|null + * @return Puc_v4_StateStore */ public function getUpdateState() { - $state = get_site_option($this->optionName, null); - if ( !is_object($state) ) { - $state = null; - } else if ( isset($state->update) && is_object($state->update) ) { - $state->update = call_user_func(array($this->updateClass, 'fromObject'), $state->update); - } - return $state; - } - - /** - * Persist the update checker state to the DB. - * - * @param StdClass $state - * @return void - */ - protected function setUpdateState($state) { - if (isset($state->update) && is_object($state->update) && method_exists($state->update, 'toStdClass')) { - $update = $state->update; - /** @var Puc_v4_Update $update */ - $state->update = $update->toStdClass(); - } - update_site_option($this->optionName, $state); + return $this->updateState->lazyLoad(); } /** @@ -233,7 +210,7 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): * clear the update cache. */ public function resetUpdateState() { - delete_site_option($this->optionName); + $this->updateState->delete(); } /** @@ -248,11 +225,10 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): * @return Puc_v4_Update|null */ public function getUpdate() { - $state = $this->getUpdateState(); /** @var StdClass $state */ + $update = $this->updateState->getUpdate(); //Is there an update available? - if ( isset($state, $state->update) ) { - $update = $state->update; + if ( isset($update) ) { //Check if the update is actually newer than the currently installed version. $installedVersion = $this->getInstalledVersion(); if ( ($installedVersion !== null) && version_compare($update->version, $installedVersion, '>') ){ @@ -619,11 +595,7 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): * @return array */ public function getTranslationUpdates() { - $state = $this->getUpdateState(); - if ( isset($state, $state->update, $state->update->translations) ) { - return $state->update->translations; - } - return array(); + return $this->updateState->getTranslations(); } /** @@ -632,11 +604,7 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): * @see wp_clean_update_cache */ public function clearCachedTranslationUpdates() { - $state = $this->getUpdateState(); - if ( isset($state, $state->update, $state->update->translations) ) { - $state->update->translations = array(); - $this->setUpdateState($state); - } + $this->updateState->setTranslations(array()); } /** From 4f1ec59f98c956498e1fc0651e8d2dca3277c8e7 Mon Sep 17 00:00:00 2001 From: Yahnis Elsts Date: Tue, 10 Jan 2017 16:20:01 +0200 Subject: [PATCH 40/44] Remove unused property --- Puc/v4/Plugin/UpdateChecker.php | 1 - Puc/v4/Theme/UpdateChecker.php | 1 - Puc/v4/UpdateChecker.php | 1 - 3 files changed, 3 deletions(-) diff --git a/Puc/v4/Plugin/UpdateChecker.php b/Puc/v4/Plugin/UpdateChecker.php index b56b86a..ca0604d 100644 --- a/Puc/v4/Plugin/UpdateChecker.php +++ b/Puc/v4/Plugin/UpdateChecker.php @@ -9,7 +9,6 @@ if ( !class_exists('Puc_v4_Plugin_UpdateChecker', false) ): * @access public */ class Puc_v4_Plugin_UpdateChecker extends Puc_v4_UpdateChecker { - protected $updateClass = 'Puc_v4_Plugin_Update'; protected $updateTransient = 'update_plugins'; protected $translationType = 'plugin'; diff --git a/Puc/v4/Theme/UpdateChecker.php b/Puc/v4/Theme/UpdateChecker.php index 56d0448..dbf4245 100644 --- a/Puc/v4/Theme/UpdateChecker.php +++ b/Puc/v4/Theme/UpdateChecker.php @@ -4,7 +4,6 @@ if ( !class_exists('Puc_v4_Theme_UpdateChecker', false) ): class Puc_v4_Theme_UpdateChecker extends Puc_v4_UpdateChecker { protected $filterSuffix = 'theme'; - protected $updateClass = 'Puc_v4_Theme_Update'; protected $updateTransient = 'update_themes'; protected $translationType = 'theme'; diff --git a/Puc/v4/UpdateChecker.php b/Puc/v4/UpdateChecker.php index f60313b..77ddec5 100644 --- a/Puc/v4/UpdateChecker.php +++ b/Puc/v4/UpdateChecker.php @@ -4,7 +4,6 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): abstract class Puc_v4_UpdateChecker { protected $filterSuffix = ''; - protected $updateClass = ''; protected $updateTransient = ''; protected $translationType = ''; //"plugin" or "theme". From d71578067df121cca2a14f9ce8278156a9e5ab27 Mon Sep 17 00:00:00 2001 From: Yahnis Elsts Date: Tue, 10 Jan 2017 16:20:33 +0200 Subject: [PATCH 41/44] Minor: Rename parameter to $collection because it accepts both arrays and objects --- Puc/v4/Utils.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Puc/v4/Utils.php b/Puc/v4/Utils.php index c36fb83..b4fd8ea 100644 --- a/Puc/v4/Utils.php +++ b/Puc/v4/Utils.php @@ -6,13 +6,13 @@ if ( !class_exists('Puc_v4_Utils', false) ): /** * Get a value from a nested array or object based on a path. * - * @param array|object|null $array Get an entry from this array. + * @param array|object|null $collection Get an entry from this array. * @param array|string $path A list of array keys in hierarchy order, or a string path like "foo.bar.baz". * @param mixed $default The value to return if the specified path is not found. * @param string $separator Path element separator. Only applies to string paths. * @return mixed */ - public static function get($array, $path, $default = null, $separator = '.') { + public static function get($collection, $path, $default = null, $separator = '.') { if ( is_string($path) ) { $path = explode($separator, $path); } @@ -21,7 +21,7 @@ if ( !class_exists('Puc_v4_Utils', false) ): } //Follow the $path into $input as far as possible. - $currentValue = $array; + $currentValue = $collection; $pathExists = true; foreach ($path as $node) { if ( is_array($currentValue) && isset($currentValue[$node]) ) { From 6ba1aeb3623377d4a7821a972924fc159e3f2b06 Mon Sep 17 00:00:00 2001 From: Yahnis Elsts Date: Tue, 10 Jan 2017 17:05:07 +0200 Subject: [PATCH 42/44] Minor: Move condition so that the fixDirectoryName filter never gets called if there's no valid directory name. --- Puc/v4/UpdateChecker.php | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/Puc/v4/UpdateChecker.php b/Puc/v4/UpdateChecker.php index 77ddec5..f583f2f 100644 --- a/Puc/v4/UpdateChecker.php +++ b/Puc/v4/UpdateChecker.php @@ -105,7 +105,9 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): ); //Rename the update directory to be the same as the existing directory. - add_filter('upgrader_source_selection', array($this, 'fixDirectoryName'), 10, 3); + if ( $this->directoryName !== '.' ) { + add_filter('upgrader_source_selection', array($this, 'fixDirectoryName'), 10, 3); + } //Allow HTTP requests to the metadata URL even if it's on a local host. add_filter('http_request_host_is_external', array($this, 'allowMetadataHost'), 10, 2); @@ -659,16 +661,13 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): } //Rename the source to match the existing directory. - if ( $this->directoryName === '.' ) { - return $source; - } $correctedSource = trailingslashit($remoteSource) . $this->directoryName . '/'; if ( $source !== $correctedSource ) { //The update archive should contain a single directory that contains the rest of plugin/theme files. //Otherwise, WordPress will try to copy the entire working directory ($source == $remoteSource). //We can't rename $remoteSource because that would break WordPress code that cleans up temporary files //after update. - if ($this->isBadDirectoryStructure($remoteSource)) { + if ( $this->isBadDirectoryStructure($remoteSource) ) { return new WP_Error( 'puc-incorrect-directory-structure', sprintf( @@ -686,7 +685,7 @@ if ( !class_exists('Puc_v4_UpdateChecker', false) ): '' . $this->directoryName . '' )); - if ($wp_filesystem->move($source, $correctedSource, true)) { + if ( $wp_filesystem->move($source, $correctedSource, true) ) { $upgrader->skin->feedback('Directory successfully renamed.'); return $correctedSource; } else { From 911d4cf7a0986eca57a685ba83b2d0b563ab0646 Mon Sep 17 00:00:00 2001 From: Yahnis Elsts Date: Tue, 10 Jan 2017 18:08:25 +0200 Subject: [PATCH 43/44] Minor documentation updates --- README.md | 12 ++++++------ plugin-update-checker.php | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 2dae361..a86fb7d 100644 --- a/README.md +++ b/README.md @@ -55,13 +55,13 @@ Getting Started } ``` - This is a complete example that shows all theme-related fields. `version` and `download_url` should be self-explanatory. The `details_url` key specifies the page that the user will see if they click the "View version 1.2.3 details" link in an update notification. + This is actually a complete example that shows all theme-related fields. `version` and `download_url` should be self-explanatory. The `details_url` key specifies the page that the user will see if they click the "View version 1.2.3 details" link in an update notification. 3. Upload the JSON file to a publicly accessible location. 4. Add the following code to the main plugin file or to the `functions.php` file: ```php require 'path/to/plugin-update-checker/plugin-update-checker.php'; - $myUpdateChecker = PucFactory::buildUpdateChecker( + $myUpdateChecker = Puc_v4_Factory::buildUpdateChecker( 'http://example.com/path/to/details.json', __FILE__, 'unique-plugin-or-theme-slug' @@ -83,7 +83,7 @@ By default, the library will check the specified URL for changes every 12 hours. - The second argument passed to `buildUpdateChecker` must be the absolute path to the main plugin file or any file in the theme directory. If you followed the "getting started" instructions, you can just use the `__FILE__` constant. - The third argument - i.e. the slug - is optional but recommended. If it's omitted, the update checker will use the name of the main plugin file as the slug (e.g. `my-cool-plugin.php` → `my-cool-plugin`). This can lead to conflicts if your plugin has a generic file name like `plugin.php`. - This doesn't affect themes as much because PUC uses the theme directory name as the default slug. Still, if you're planning to use the slug in your own code - e.g. to filter updates or override update checker behaviour - it can be a good idea to set it explicitly. + This doesn't affect themes because PUC uses the theme directory name as the default slug. Still, if you're planning to use the slug in your own code - e.g. to filter updates or override update checker behaviour - it can be a good idea to set it explicitly. ### GitHub Integration @@ -92,7 +92,7 @@ By default, the library will check the specified URL for changes every 12 hours. ```php require 'plugin-update-checker/plugin-update-checker.php'; - $myUpdateChecker = PucFactory::buildUpdateChecker( + $myUpdateChecker = Puc_v4_Factory::buildUpdateChecker( 'https://github.com/user-name/repo-name/', __FILE__, 'unique-plugin-or-theme-slug' @@ -166,7 +166,7 @@ The library will pull update details from the following parts of a release/tag/b ```php require 'plugin-update-checker/plugin-update-checker.php'; - $myUpdateChecker = PucFactory::buildUpdateChecker( + $myUpdateChecker = Puc_v4_Factory::buildUpdateChecker( 'https://bitbucket.org/user-name/repo-name', __FILE__, 'unique-plugin-or-theme-slug' @@ -182,7 +182,7 @@ The library will pull update details from the following parts of a release/tag/b //Optional: Set the branch that contains the stable release. $myUpdateChecker->setBranch('stable-branch-name'); ``` -3. Plugins only: Add a `readme.txt` file formatted according to the [WordPress.org plugin readme standard](https://wordpress.org/plugins/about/readme.txt) to your repository. The contents of this file will be shown when the user clicks the "View version 1.2.3 details" link. +3. Optional: Add a `readme.txt` file formatted according to the [WordPress.org plugin readme standard](https://wordpress.org/plugins/about/readme.txt) to your repository. For plugins, the contents of this file will be shown when the user clicks the "View version 1.2.3 details" link. #### How to Release an Update diff --git a/plugin-update-checker.php b/plugin-update-checker.php index 642809a..8aff3eb 100644 --- a/plugin-update-checker.php +++ b/plugin-update-checker.php @@ -3,7 +3,7 @@ * Plugin Update Checker Library 4.0 * http://w-shadow.com/ * - * Copyright 2016 Janis Elsts + * Copyright 2017 Janis Elsts * Released under the MIT license. See license.txt for details. */ From e1be09dc44b255353e02541af3eca22483620189 Mon Sep 17 00:00:00 2001 From: Yahnis Elsts Date: Wed, 11 Jan 2017 11:19:20 +0200 Subject: [PATCH 44/44] Move blog post link down to the "resources" because it's outdated --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index a86fb7d..4092e5e 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,6 @@ This is a custom update checker library for WordPress plugins and themes. It let From the users' perspective, it works just like with plugins and themes hosted on WordPress.org. The update checker uses the default upgrade UI that is familiar to most WordPress users. -[See this blog post](http://w-shadow.com/blog/2010/09/02/automatic-updates-for-any-plugin/) for more information. - **Table of Contents** @@ -215,6 +213,7 @@ BitBucket doesn't have an equivalent to GitHub's releases, so the process is sli Resources --------- +- [This blog post](http://w-shadow.com/blog/2010/09/02/automatic-updates-for-any-plugin/) has more information about the update checker API. *Slightly out of date.* - [Debug Bar](https://wordpress.org/plugins/debug-bar/) - useful for testing and debugging the update checker. - [Securing download links](http://w-shadow.com/blog/2013/03/19/plugin-updates-securing-download-links/) - a general overview. - [A GUI for entering download credentials](http://open-tools.net/documentation/tutorial-automatic-updates.html#wordpress)