<?php
/**
* Plugin Update Checker Library 3.2
* http://w-shadow.com/
*
* Copyright 2016 Janis Elsts
* Released under the MIT license. See license.txt for details.
*/
if ( !class_exists('SpeedyCacheUpdateChecker_3_2', false) ):
/**
* A custom plugin update checker.
*
* @author Janis Elsts
* @copyright 2016
* @version 3.2
* @access public
*/
#[\AllowDynamicProperties]
class SpeedyCacheUpdateChecker_3_2 {
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()
//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.
*
* @param string $metadataUrl The URL of the plugin's metadata file.
* @param string $pluginFile Fully qualified path to the main plugin file.
* @param string $slug The plugin's 'slug'. If not specified, the filename part of $pluginFile sans '.php' will be used as the slug.
* @param integer $checkPeriod How often to check for updates (in hours). Defaults to checking every 12 hours. Set to 0 to disable automatic update checks.
* @param string $optionName Where to store book-keeping info about update checks. Defaults to 'external_updates-$slug'.
* @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');
}
//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 SpeedyCache_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 SpeedyCache_PucScheduler_3_2
*/
protected function createScheduler($checkPeriod) {
return new SpeedyCache_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 SpeedyCacheInfo_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 = SpeedyCacheInfo_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 SpeedyCacheUpdateChecker::requestInfo()
*
* @return SpeedyCacheUpdate_3_2 An instance of SpeedyCacheUpdate, 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 = SpeedyCacheUpdate_3_2::fromSpeedyCacheInfo($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 SpeedyCacheUpdate_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 = SpeedyCacheUpdate_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 SpeedyCacheUpdate_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