Files
Keith Solomon 2bbd2dc7c8 Update bundled plugin-update-checker to v5.7 and remove broken setBranch() call
The bundled PUC was missing the setBranch() method on the main UpdateChecker
class (it's only on Vcs\PluginUpdateChecker via the VcsCheckerMethods trait).
When called with a non-VCS URL like the Gitea update URL, this caused a fatal
error at plugin activation.

Updating to PUC v5.7 (May 2026) which is the latest upstream release. The
setBranch() call is removed because PUC doesn't recognize Gitea as a VCS host;
the plugin falls back to PUC's plain JSON metadata mode, which is fine for a
plugin hosted on a self-managed repo.
2026-08-11 16:08:20 -05:00

1182 lines
37 KiB
PHP

<?php
namespace YahnisElsts\PluginUpdateChecker\v5p7;
use stdClass;
use WP_Error;
if ( !class_exists(UpdateChecker::class, false) ):
abstract class UpdateChecker {
protected $filterSuffix = '';
protected $updateTransient = '';
/**
* @var string This can be "plugin" or "theme".
*/
protected $componentType = '';
/**
* @var string Currently the same as $componentType, but this is an implementation detail that
* depends on how WP works internally, and could therefore change.
*/
protected $translationType = '';
/**
* 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 = null;
/**
* @var string Where to store the update info.
*/
public $optionName = '';
/**
* @var string The URL of the metadata file.
*/
public $metadataUrl = '';
/**
* @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 = '';
/**
* @var InstalledPackage
*/
protected $package;
/**
* @var Scheduler
*/
public $scheduler;
/**
* @var UpgraderStatus
*/
protected $upgraderStatus;
/**
* @var StateStore
*/
protected $updateState;
/**
* @var array List of API errors triggered during the last checkForUpdates() call.
*/
protected $lastRequestApiErrors = array();
/**
* @var string|mixed The default is 0 because parse_url() can return NULL or FALSE.
*/
protected $cachedMetadataHost = 0;
/**
* @var DebugBar\Extension|null
*/
protected $debugBarExtension = null;
/**
* @var WpCliCheckTrigger|null
*/
protected $wpCliCheckTrigger = null;
public function __construct($metadataUrl, $directoryName, $slug = null, $checkPeriod = 12, $optionName = '') {
$this->debugMode = (bool)(constant('WP_DEBUG'));
$this->metadataUrl = $metadataUrl;
$this->directoryName = $directoryName;
$this->slug = !empty($slug) ? $slug : $this->directoryName;
$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->filterSuffix === '' ) {
$this->optionName = 'external_updates-' . $this->slug;
} else {
$this->optionName = $this->getUniqueName('external_updates');
}
}
if ( empty($this->translationType) ) {
$this->translationType = $this->componentType;
}
$this->package = $this->createInstalledPackage();
$this->scheduler = $this->createScheduler($checkPeriod);
$this->upgraderStatus = new UpgraderStatus();
$this->updateState = new StateStore($this->optionName);
if ( did_action('init') ) {
$this->loadTextDomain();
} else {
add_action('init', array($this, 'loadTextDomain'));
}
$this->installHooks();
if ( ($this->wpCliCheckTrigger === null) && defined('WP_CLI') ) {
$this->wpCliCheckTrigger = new WpCliCheckTrigger($this->componentType, $this->scheduler);
}
}
/**
* @internal
*/
public 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() && function_exists('get_user_locale')) ? 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() {
//Insert our update info into the update array maintained by WP.
add_filter('site_transient_' . $this->updateTransient, array($this,'injectUpdate'));
//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')
);
//Rename the update directory to be the same as the existing directory.
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);
//Potentially exclude information about this entity from core update check requests to api.wordpress.org.
//phpcs:ignore WordPressVIPMinimum.Hooks.RestrictedHooks.http_request_args -- Doesn't modify timeouts.
add_filter('http_request_args', array($this, 'excludeEntityFromWordPressAPI'), 10, 2);
//DebugBar integration.
if ( did_action('plugins_loaded') ) {
$this->maybeInitDebugBar();
} else {
add_action('plugins_loaded', array($this, 'maybeInitDebugBar'));
}
}
/**
* Remove hooks that were added by this update checker instance.
*/
public function removeHooks() {
remove_filter('site_transient_' . $this->updateTransient, array($this,'injectUpdate'));
remove_filter('site_transient_' . $this->updateTransient, array($this, 'injectTranslationUpdates'));
remove_action(
'delete_site_transient_' . $this->updateTransient,
array($this, 'clearCachedTranslationUpdates')
);
remove_filter('upgrader_source_selection', array($this, 'fixDirectoryName'), 10);
remove_filter('http_request_host_is_external', array($this, 'allowMetadataHost'), 10);
remove_filter('http_request_args', array($this, 'excludeEntityFromWordPressAPI'));
remove_action('plugins_loaded', array($this, 'maybeInitDebugBar'));
remove_action('init', array($this, 'loadTextDomain'));
if ( $this->scheduler ) {
$this->scheduler->removeHooks();
}
if ( $this->debugBarExtension ) {
$this->debugBarExtension->removeHooks();
}
}
/**
* 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.
*
* 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 ( $this->cachedMetadataHost === 0 ) {
$this->cachedMetadataHost = wp_parse_url($this->metadataUrl, PHP_URL_HOST);
}
if ( is_string($this->cachedMetadataHost) && (strtolower($host) === strtolower($this->cachedMetadataHost)) ) {
return true;
}
return $allow;
}
/**
* Create a package instance that represents this plugin or theme.
*
* @return InstalledPackage
*/
abstract protected function createInstalledPackage();
/**
* @return InstalledPackage
*/
public function getInstalledPackage() {
return $this->package;
}
/**
* 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 Scheduler
*/
abstract protected function createScheduler($checkPeriod);
/**
* Remove information about this plugin or theme from the requests that WordPress core sends
* to api.wordpress.org when checking for updates.
*
* @param array $args
* @param string $url
* @return array
*/
public function excludeEntityFromWordPressAPI($args, $url) {
//Is this an api.wordpress.org update check request?
$parsedUrl = wp_parse_url($url);
if ( !isset($parsedUrl['host']) || (strtolower($parsedUrl['host']) !== 'api.wordpress.org') ) {
return $args;
}
$typePluralised = $this->componentType . 's';
$expectedPathPrefix = '/' . $typePluralised . '/update-check/1.'; //e.g. "/plugins/update-check/1.1/"
if ( !isset($parsedUrl['path']) || !Utils::startsWith($parsedUrl['path'], $expectedPathPrefix) ) {
return $args;
}
//Plugins and themes can disable this feature by using the filter below.
if ( !apply_filters(
$this->getUniqueName('remove_from_default_update_checks'),
true, $this, $args, $url
) ) {
return $args;
}
if ( empty($args['body'][$typePluralised]) ) {
return $args;
}
$reportingItems = json_decode($args['body'][$typePluralised], true);
if ( $reportingItems === null ) {
return $args;
}
//The list of installed items uses different key formats for plugins and themes.
//Luckily, we can reuse the getUpdateListKey() method here.
$updateListKey = $this->getUpdateListKey();
if ( isset($reportingItems[$typePluralised][$updateListKey]) ) {
unset($reportingItems[$typePluralised][$updateListKey]);
}
if ( !empty($reportingItems['active']) ) {
if ( is_array($reportingItems['active']) ) {
foreach ($reportingItems['active'] as $index => $relativePath) {
if ( $relativePath === $updateListKey ) {
unset($reportingItems['active'][$index]);
}
}
//Re-index the array.
$reportingItems['active'] = array_values($reportingItems['active']);
} else if ( $reportingItems['active'] === $updateListKey ) {
//For themes, the "active" field is a string that contains the theme's directory name.
//Pretend that the default theme is active so that we don't reveal the actual theme.
if ( defined('WP_DEFAULT_THEME') ) {
$reportingItems['active'] = WP_DEFAULT_THEME;
}
//Unfortunately, it doesn't seem to be documented if we can safely remove the "active"
//key. So when we don't know the default theme, we'll just leave it as is.
}
}
$args['body'][$typePluralised] = wp_json_encode($reportingItems);
return $args;
}
/**
* Check for updates. The results are stored in the DB option specified in $optionName.
*
* @return 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;
}
//Start collecting API errors.
$this->lastRequestApiErrors = array();
add_action('puc_api_error', array($this, 'collectApiErrors'), 10, 4);
$state = $this->updateState;
$state->setLastCheckToNow()
->setCheckedVersion($installedVersion)
->save(); //Save before checking in case something goes wrong
$state->setUpdate($this->requestUpdate());
$state->save();
//Stop collecting API errors.
remove_action('puc_api_error', array($this, 'collectApiErrors'), 10);
return $this->getUpdate();
}
/**
* Load the update checker state from the DB.
*
* @return StateStore
*/
public function getUpdateState() {
return $this->updateState->lazyLoad();
}
/**
* 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() {
$this->updateState->delete();
}
/**
* 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 Update|null
*/
public function getUpdate() {
$update = $this->updateState->getUpdate();
//Is there an update available?
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, '>') ){
return $update;
}
}
return null;
}
/**
* Retrieve the latest update (if any) from the configured API endpoint.
*
* Subclasses should run the update through filterUpdateResult before returning it.
*
* @return Update An instance of Update, or NULL when no updates are available.
*/
abstract public function requestUpdate();
/**
* Filter the result of a requestUpdate() call.
*
* @template T of Update
* @param T|null $update
* @param array|WP_Error|null $httpResult The value returned by wp_remote_get(), if any.
* @return T
*/
protected function filterUpdateResult($update, $httpResult = null) {
//Let plugins/themes modify the update.
$update = apply_filters($this->getUniqueName('request_update_result'), $update, $httpResult);
$this->fixSupportedWordpressVersion($update);
if ( isset($update, $update->translations) ) {
//Keep only those translation updates that apply to this site.
$update->translations = $this->filterApplicableTranslations($update->translations);
}
return $update;
}
/**
* The "Tested up to" field in the plugin metadata is supposed to be in the form of "major.minor",
* while WordPress core's list_plugin_updates() expects the $update->tested field to be an exact
* version, e.g. "major.minor.patch", to say it's compatible. In other case it shows
* "Compatibility: Unknown".
* The function mimics how wordpress.org API crafts the "tested" field out of "Tested up to".
*
* @param Metadata|null $update
*/
protected function fixSupportedWordpressVersion($update = null) {
if ( !isset($update->tested) || !preg_match('/^\d++\.\d++$/', $update->tested) ) {
return;
}
$actualWpVersions = array();
$wpVersion = $GLOBALS['wp_version'];
if ( function_exists('get_core_updates') ) {
$coreUpdates = get_core_updates();
if ( is_array($coreUpdates) ) {
foreach ($coreUpdates as $coreUpdate) {
if ( isset($coreUpdate->current) ) {
$actualWpVersions[] = $coreUpdate->current;
}
}
}
}
$actualWpVersions[] = $wpVersion;
$actualWpPatchNumber = null;
foreach ($actualWpVersions as $version) {
if ( preg_match('/^(?P<majorMinor>\d++\.\d++)(?:\.(?P<patch>\d++))?/', $version, $versionParts) ) {
if ( $versionParts['majorMinor'] === $update->tested ) {
$patch = isset($versionParts['patch']) ? intval($versionParts['patch']) : 0;
if ( $actualWpPatchNumber === null ) {
$actualWpPatchNumber = $patch;
} else {
$actualWpPatchNumber = max($actualWpPatchNumber, $patch);
}
}
}
}
if ( $actualWpPatchNumber === null ) {
$actualWpPatchNumber = 999;
}
if ( $actualWpPatchNumber > 0 ) {
$update->tested .= '.' . $actualWpPatchNumber;
}
}
/**
* Get the currently installed version of the plugin or theme.
*
* @return string|null Version number.
*/
public function getInstalledVersion() {
return $this->package->getInstalledVersion();
}
/**
* Get the full path of the plugin or theme directory.
*
* @return string
*/
public function getAbsoluteDirectoryPath() {
return $this->package->getAbsoluteDirectoryPath();
}
/**
* Trigger a PHP error, but only when $debugMode is enabled.
*
* @param string $message
* @param int $errorType
*/
public function triggerError($message, $errorType) {
if ( $this->isDebugModeEnabled() ) {
//phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error -- Only happens in debug mode.
trigger_error(esc_html($message), $errorType);
}
}
/**
* @return bool
*/
protected function isDebugModeEnabled() {
if ( $this->debugMode === null ) {
$this->debugMode = (bool)(constant('WP_DEBUG'));
}
return $this->debugMode;
}
/**
* 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;
}
/**
* Store API errors that are generated when checking for updates.
*
* @internal
* @param \WP_Error $error
* @param array|null $httpResponse
* @param string|null $url
* @param string|null $slug
*/
public function collectApiErrors($error, $httpResponse = null, $url = null, $slug = null) {
if ( isset($slug) && ($slug !== $this->slug) ) {
return;
}
$this->lastRequestApiErrors[] = array(
'error' => $error,
'httpResponse' => $httpResponse,
'url' => $url,
);
}
/**
* @return array
*/
public function getLastRequestApiErrors() {
return $this->lastRequestApiErrors;
}
/* -------------------------------------------------------------------
* 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
* -------------------------------------------------------------------
*/
/**
* 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->getUniqueName('pre_inject_update'), $update);
//Convert the update into the format used by WordPress core.
$wpUpdate = $update->toWpFormat();
//Disable the "autoupdate" flag unless explicitly allowed. This is a safety precaution;
//untrusted or compromised update sources could otherwise set this flag to true even
//if the plugin/theme developer didn't intend to allow automatic update installation.
if ( isset($wpUpdate->autoupdate) && !$this->isAutoupdateFieldAllowed($update) ) {
$wpUpdate->autoupdate = false;
}
$updates = $this->addUpdateToList($updates, $wpUpdate);
} else {
//Clean up any stale update info.
$updates = $this->removeUpdateFromList($updates);
//Add a placeholder item to the "no_update" list to enable auto-update support.
//If we don't do this, the option to enable automatic updates will only show up
//when an update is available.
$updates = $this->addNoUpdateItem($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;
}
/**
* See this post for more information:
* @link https://make.wordpress.org/core/2020/07/30/recommended-usage-of-the-updates-api-to-support-the-auto-updates-ui-for-plugins-and-themes-in-wordpress-5-5/
*
* @param \stdClass|null $updates
* @return \stdClass
*/
protected function addNoUpdateItem($updates) {
if ( !is_object($updates) ) {
$updates = new stdClass();
$updates->response = array();
$updates->no_update = array();
} else if ( !isset($updates->no_update) ) {
$updates->no_update = array();
}
$updates->no_update[$this->getUpdateListKey()] = (object) $this->getNoUpdateItemFields();
return $updates;
}
/**
* Subclasses should override this method to add fields that are specific to plugins or themes.
* @return array
*/
protected function getNoUpdateItemFields() {
return array(
'new_version' => $this->getInstalledVersion(),
'url' => '',
'package' => '',
'requires_php' => '',
);
}
/**
* 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?
*
* 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;
}
/**
* @var bool
*/
protected $autoupdateFieldAllowed = false;
/**
* Allow the "autoupdate" field in incoming plugin updates to be set to `true`.
*
* By default, the update checker will parse the field (so it will be available in the update
* object), but will set it to `false` before passing the update to WordPress.
*
* @return $this
*/
public function allowAutoupdateField() {
$this->autoupdateFieldAllowed = true;
return $this;
}
/**
* Is the "autoupdate" field for injected updates allowed to be set to true?
*
* @param object $incomingUpdate
* @return bool
*/
protected function isAutoupdateFieldAllowed($incomingUpdate) {
return apply_filters(
$this->getUniqueName('autoupdate_field_allowed'),
$this->autoupdateFieldAllowed,
$incomingUpdate
);
}
/* -------------------------------------------------------------------
* JSON-based update API
* -------------------------------------------------------------------
*/
/**
* Retrieve plugin or theme metadata from the JSON document at $this->metadataUrl.
*
* @param class-string<Update> $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<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()).
$queryArgs = array_merge(
array(
'installed_version' => strval($this->getInstalledVersion()),
'php' => phpversion(),
'locale' => get_locale(),
),
$queryArgs
);
$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' => wp_doing_cron() ? 10 : 3,
'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);
$result = apply_filters($this->getUniqueName('request_metadata_http_result'), $result, $url, $options);
//Try to parse the response
$status = $this->validateApiResponse($result);
$metadata = null;
if ( !is_wp_error($status) ){
if ( (strpos($metaClass, '\\') === false) ) {
$metaClass = __NAMESPACE__ . '\\' . $metaClass;
}
$metadata = call_user_func(array($metaClass, 'fromJson'), $result['body']);
} else {
do_action('puc_api_error', $status, $result, $url, $this->slug);
$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
* -------------------------------------------------------------------
*/
/**
* 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() {
if ( !function_exists('wp_get_installed_translations') ) {
return array();
}
$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.
$updates->translations = array_values(array_filter(
$updates->translations,
array($this, 'isNotMyTranslation')
));
//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() {
return $this->updateState->getTranslations();
}
/**
* Remove all cached translation updates.
*
* @see wp_clean_update_cache
*/
public function clearCachedTranslationUpdates() {
$this->updateState->setTranslations(array());
}
/**
* 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
* -------------------------------------------------------------------
*/
/**
* 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;
}
//Fix the remote source structure if necessary.
//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) ) {
//Create a new directory using the plugin slug.
$newDirectory = trailingslashit($remoteSource) . $this->slug . '/';
if ( !$wp_filesystem->is_dir($newDirectory) ) {
$wp_filesystem->mkdir($newDirectory);
//Move all files to the newly created directory.
$sourceFiles = $wp_filesystem->dirlist($remoteSource);
if ( is_array($sourceFiles) ) {
$sourceFiles = array_keys($sourceFiles);
$allMoved = true;
foreach ($sourceFiles as $filename) {
//Skip our newly created folder.
if ( $filename === $this->slug ) {
continue;
}
$previousSource = trailingslashit($remoteSource) . $filename;
$newSource = trailingslashit($newDirectory) . $filename;
if ( !$wp_filesystem->move($previousSource, $newSource, true) ) {
$allMoved = false;
break;
}
}
if ( $allMoved ) {
//Rename source.
$source = $newDirectory;
} else {
//Delete our newly created folder including all files in it.
$wp_filesystem->rmdir($newDirectory, true);
//And return a relevant error.
return new WP_Error(
'puc-incorrect-directory-structure',
sprintf(
'The directory structure of the update was incorrect. All files should be inside ' .
'a directory named <span class="code">%s</span>, not at the root of the ZIP archive. Plugin Update Checker tried to fix the directory structure, but failed.',
esc_html($this->slug)
)
);
}
}
}
}
//Rename the source to match the existing directory.
$correctedSource = trailingslashit($remoteSource) . $this->directoryName . '/';
if ( $source !== $correctedSource ) {
$upgrader->skin->feedback(sprintf(
'Renaming %s to %s&#8230;',
'<span class="code">' . basename($source) . '</span>',
'<span class="code">' . $this->directoryName . '</span>'
));
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;
}
/* -------------------------------------------------------------------
* DebugBar integration
* -------------------------------------------------------------------
*/
/**
* Initialize the update checker Debug Bar plugin/add-on thingy.
*/
public function maybeInitDebugBar() {
if (
class_exists('Debug_Bar', false)
&& class_exists('Debug_Bar_Panel', false)
&& file_exists(dirname(__FILE__) . '/DebugBar')
) {
$this->debugBarExtension = $this->createDebugBarExtension();
}
}
protected function createDebugBarExtension() {
return new DebugBar\Extension($this);
}
/**
* Display additional configuration details in the Debug Bar panel.
*
* @param DebugBar\Panel $panel
*/
public function onDisplayConfiguration($panel) {
//Do nothing. Subclasses can use this to add additional info to the panel.
}
}
endif;