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.
This commit is contained in:
Keith Solomon
2026-08-11 16:08:20 -05:00
parent 93af230406
commit 2bbd2dc7c8
56 changed files with 722 additions and 2192 deletions
@@ -4,7 +4,7 @@ namespace YahnisElsts\PluginUpdateChecker\v5;
if ( !class_exists(PucFactory::class, false) ):
class PucFactory extends \YahnisElsts\PluginUpdateChecker\v5p4\PucFactory {
class PucFactory extends \YahnisElsts\PluginUpdateChecker\v5p7\PucFactory {
}
endif;
@@ -1,6 +1,6 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4;
namespace YahnisElsts\PluginUpdateChecker\v5p7;
if ( !class_exists(Autoloader::class, false) ):
@@ -1,8 +1,8 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4\DebugBar;
namespace YahnisElsts\PluginUpdateChecker\v5p7\DebugBar;
use YahnisElsts\PluginUpdateChecker\v5p4\PucFactory;
use YahnisElsts\PluginUpdateChecker\v5p4\UpdateChecker;
use YahnisElsts\PluginUpdateChecker\v5p7\PucFactory;
use YahnisElsts\PluginUpdateChecker\v5p7\UpdateChecker;
if ( !class_exists(Extension::class, false) ):
@@ -1,7 +1,7 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4\DebugBar;
namespace YahnisElsts\PluginUpdateChecker\v5p7\DebugBar;
use YahnisElsts\PluginUpdateChecker\v5p4\UpdateChecker;
use YahnisElsts\PluginUpdateChecker\v5p7\UpdateChecker;
if ( !class_exists(Panel::class, false) && class_exists('Debug_Bar_Panel', false) ):
@@ -41,11 +41,11 @@ if ( !class_exists(Panel::class, false) && class_exists('Debug_Bar_Panel', false
echo '<h3>Configuration</h3>';
echo '<table class="puc-debug-data">';
$this->displayConfigHeader();
$this->row('Slug', htmlentities($this->updateChecker->slug));
$this->row('DB option', htmlentities($this->updateChecker->optionName));
$this->row('Slug', esc_html($this->updateChecker->slug));
$this->row('DB option', esc_html($this->updateChecker->optionName));
$requestInfoButton = $this->getMetadataButton();
$this->row('Metadata URL', htmlentities($this->updateChecker->metadataUrl) . ' ' . $requestInfoButton . $this->responseBox);
$this->row('Metadata URL', esc_html($this->updateChecker->metadataUrl) . ' ' . $requestInfoButton . $this->responseBox);
$scheduler = $this->updateChecker->scheduler;
if ( $scheduler->checkPeriod > 0 ) {
@@ -86,14 +86,22 @@ if ( !class_exists(Panel::class, false) && class_exists('Debug_Bar_Panel', false
echo '<h3>Status</h3>';
echo '<table class="puc-debug-data">';
$state = $this->updateChecker->getUpdateState();
$checkNowButton = '';
$checkButtonId = $this->updateChecker->getUniqueName('check-now-button');
if ( function_exists('get_submit_button') ) {
$checkNowButton = get_submit_button(
'Check Now',
'secondary',
'puc-check-now-button',
false,
array('id' => $this->updateChecker->getUniqueName('check-now-button'))
array('id' => $checkButtonId)
);
} else {
//get_submit_button() is not available in the frontend. Make a button directly.
//It won't look the same without admin styles, but it should still work.
$checkNowButton = sprintf(
'<input type="button" id="%1$s" name="puc-check-now-button" value="%2$s" class="button button-secondary" />',
esc_attr($checkButtonId),
esc_attr('Check Now')
);
}
@@ -107,10 +115,10 @@ if ( !class_exists(Panel::class, false) && class_exists('Debug_Bar_Panel', false
$this->row('Next automatic check', $this->formatTimeWithDelta($nextCheck));
if ( $state->getCheckedVersion() !== '' ) {
$this->row('Checked version', htmlentities($state->getCheckedVersion()));
$this->row('Checked version', esc_html($state->getCheckedVersion()));
$this->row('Cached update', $state->getUpdate());
}
$this->row('Update checker class', htmlentities(get_class($this->updateChecker)));
$this->row('Update checker class', esc_html(get_class($this->updateChecker)));
echo '</table>';
}
@@ -124,7 +132,7 @@ if ( !class_exists(Panel::class, false) && class_exists('Debug_Bar_Panel', false
if ( property_exists($update, $field) ) {
$this->row(
ucwords(str_replace('_', ' ', $field)),
isset($update->$field) ? htmlentities($update->$field) : null
isset($update->$field) ? esc_html($update->$field) : null
);
}
}
@@ -162,7 +170,7 @@ if ( !class_exists(Panel::class, false) && class_exists('Debug_Bar_Panel', false
if ( is_object($value) || is_array($value) ) {
//This is specifically for debugging, so print_r() is fine.
//phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r
$value = '<pre>' . htmlentities(print_r($value, true)) . '</pre>';
$value = '<pre>' . esc_html(print_r($value, true)) . '</pre>';
} else if ($value === null) {
$value = '<code>null</code>';
}
@@ -1,8 +1,8 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4\DebugBar;
namespace YahnisElsts\PluginUpdateChecker\v5p7\DebugBar;
use YahnisElsts\PluginUpdateChecker\v5p4\Plugin\UpdateChecker;
use YahnisElsts\PluginUpdateChecker\v5p7\Plugin\UpdateChecker;
if ( !class_exists(PluginExtension::class, false) ):
@@ -1,7 +1,7 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4\DebugBar;
namespace YahnisElsts\PluginUpdateChecker\v5p7\DebugBar;
use YahnisElsts\PluginUpdateChecker\v5p4\Plugin\UpdateChecker;
use YahnisElsts\PluginUpdateChecker\v5p7\Plugin\UpdateChecker;
if ( !class_exists(PluginPanel::class, false) ):
@@ -12,19 +12,25 @@ if ( !class_exists(PluginPanel::class, false) ):
protected $updateChecker;
protected function displayConfigHeader() {
$this->row('Plugin file', htmlentities($this->updateChecker->pluginFile));
$this->row('Plugin file', esc_html($this->updateChecker->pluginFile));
parent::displayConfigHeader();
}
protected function getMetadataButton() {
$requestInfoButton = '';
$buttonId = $this->updateChecker->getUniqueName('request-info-button');
if ( function_exists('get_submit_button') ) {
$requestInfoButton = get_submit_button(
'Request Info',
'secondary',
'puc-request-info-button',
false,
array('id' => $this->updateChecker->getUniqueName('request-info-button'))
array('id' => $buttonId)
);
} else {
$requestInfoButton = sprintf(
'<input type="button" name="puc-request-info-button" id="%1$s" value="%2$s" class="button button-secondary" />',
esc_attr($buttonId),
esc_attr('Request Info')
);
}
return $requestInfoButton;
@@ -1,8 +1,8 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4\DebugBar;
namespace YahnisElsts\PluginUpdateChecker\v5p7\DebugBar;
use YahnisElsts\PluginUpdateChecker\v5p4\Theme\UpdateChecker;
use YahnisElsts\PluginUpdateChecker\v5p7\Theme\UpdateChecker;
if ( !class_exists(ThemePanel::class, false) ):
@@ -13,7 +13,7 @@ if ( !class_exists(ThemePanel::class, false) ):
protected $updateChecker;
protected function displayConfigHeader() {
$this->row('Theme directory', htmlentities($this->updateChecker->directoryName));
$this->row('Theme directory', esc_html($this->updateChecker->directoryName));
parent::displayConfigHeader();
}
@@ -1,5 +1,5 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4;
namespace YahnisElsts\PluginUpdateChecker\v5p7;
if ( !class_exists(InstalledPackage::class, false) ):
@@ -1,5 +1,5 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4;
namespace YahnisElsts\PluginUpdateChecker\v5p7;
use LogicException;
use stdClass;
@@ -1,5 +1,5 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4;
namespace YahnisElsts\PluginUpdateChecker\v5p7;
if ( !class_exists(OAuthSignature::class, false) ):
@@ -1,8 +1,8 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4\Plugin;
namespace YahnisElsts\PluginUpdateChecker\v5p7\Plugin;
use YahnisElsts\PluginUpdateChecker\v5p4\InstalledPackage;
use YahnisElsts\PluginUpdateChecker\v5p4\PucFactory;
use YahnisElsts\PluginUpdateChecker\v5p7\InstalledPackage;
use YahnisElsts\PluginUpdateChecker\v5p7\PucFactory;
if ( !class_exists(Package::class, false) ):
@@ -1,7 +1,7 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4\Plugin;
namespace YahnisElsts\PluginUpdateChecker\v5p7\Plugin;
use YahnisElsts\PluginUpdateChecker\v5p4\Metadata;
use YahnisElsts\PluginUpdateChecker\v5p7\Metadata;
if ( !class_exists(PluginInfo::class, false) ):
@@ -39,6 +39,7 @@ if ( !class_exists(PluginInfo::class, false) ):
public $downloaded;
public $active_installs;
public $last_updated;
public $autoupdate = false;
public $id = 0; //The native WP.org API returns numeric plugin IDs, but they're not used for anything.
@@ -1,5 +1,5 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4\Plugin;
namespace YahnisElsts\PluginUpdateChecker\v5p7\Plugin;
if ( !class_exists(Ui::class, false) ):
/**
@@ -187,7 +187,7 @@ if ( !class_exists(Ui::class, false) ):
}
}
wp_redirect(add_query_arg(
wp_safe_redirect(add_query_arg(
array(
'puc_update_check_result' => $status,
'puc_slug' => $this->updateChecker->slug,
@@ -1,7 +1,7 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4\Plugin;
namespace YahnisElsts\PluginUpdateChecker\v5p7\Plugin;
use YahnisElsts\PluginUpdateChecker\v5p4\Update as BaseUpdate;
use YahnisElsts\PluginUpdateChecker\v5p7\Update as BaseUpdate;
if ( !class_exists(Update::class, false) ):
@@ -20,9 +20,10 @@ if ( !class_exists(Update::class, false) ):
public $requires_php = false;
public $icons = array();
public $filename; //Plugin filename relative to the plugins directory.
public $autoupdate = false;
protected static $extraFields = array(
'id', 'homepage', 'tested', 'requires_php', 'upgrade_notice', 'icons', 'filename',
'id', 'homepage', 'tested', 'requires_php', 'upgrade_notice', 'icons', 'filename', 'autoupdate',
);
/**
@@ -86,6 +87,7 @@ if ( !class_exists(Update::class, false) ):
$update->tested = $this->tested;
$update->requires_php = $this->requires_php;
$update->plugin = $this->filename;
$update->autoupdate = $this->autoupdate;
if ( !empty($this->upgrade_notice) ) {
$update->upgrade_notice = $this->upgrade_notice;
@@ -102,7 +104,7 @@ if ( !class_exists(Update::class, false) ):
$update->icons = $icons;
//It appears that the 'default' icon isn't used anywhere in WordPress 4.9,
//but lets set it just in case a future release needs it.
//but let's set it just in case a future release needs it.
if ( !isset($update->icons['default']) ) {
$update->icons['default'] = current($update->icons);
}
@@ -1,10 +1,10 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4\Plugin;
namespace YahnisElsts\PluginUpdateChecker\v5p7\Plugin;
use YahnisElsts\PluginUpdateChecker\v5p4\InstalledPackage;
use YahnisElsts\PluginUpdateChecker\v5p4\UpdateChecker as BaseUpdateChecker;
use YahnisElsts\PluginUpdateChecker\v5p4\Scheduler;
use YahnisElsts\PluginUpdateChecker\v5p4\DebugBar;
use YahnisElsts\PluginUpdateChecker\v5p7\InstalledPackage;
use YahnisElsts\PluginUpdateChecker\v5p7\UpdateChecker as BaseUpdateChecker;
use YahnisElsts\PluginUpdateChecker\v5p7\Scheduler;
use YahnisElsts\PluginUpdateChecker\v5p7\DebugBar;
if ( !class_exists(UpdateChecker::class, false) ):
@@ -1,10 +1,10 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4;
namespace YahnisElsts\PluginUpdateChecker\v5p7;
use YahnisElsts\PluginUpdateChecker\v5p4\Plugin;
use YahnisElsts\PluginUpdateChecker\v5p4\Theme;
use YahnisElsts\PluginUpdateChecker\v5p4\Vcs;
use YahnisElsts\PluginUpdateChecker\v5p7\Plugin;
use YahnisElsts\PluginUpdateChecker\v5p7\Theme;
use YahnisElsts\PluginUpdateChecker\v5p7\Vcs;
if ( !class_exists(PucFactory::class, false) ):
@@ -86,7 +86,7 @@ if ( !class_exists(PucFactory::class, false) ):
throw new \RuntimeException(sprintf(
'The update checker cannot determine if "%s" is a plugin or a theme. ' .
'This is a bug. Please contact the PUC developer.',
htmlentities($fullPath)
esc_html($fullPath)
));
}
@@ -147,7 +147,7 @@ if ( !class_exists(PucFactory::class, false) ):
*
* Normalize a filesystem path. Introduced in WP 3.9.
* Copying here allows use of the class on earlier versions.
* This version adapted from WP 4.8.2 (unchanged since 4.5.4)
* This version adapted from WP 4.8.2 (unchanged since 4.5.7)
*
* @param string $path Path to normalize.
* @return string Normalized path.
@@ -239,7 +239,7 @@ if ( !class_exists(PucFactory::class, false) ):
//URI was not found so throw an error.
throw new \RuntimeException(
sprintf('Unable to locate URI in header of "%s"', htmlentities($fullPath))
sprintf('Unable to locate URI in header of "%s"', esc_html($fullPath))
);
}
@@ -1,5 +1,5 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4;
namespace YahnisElsts\PluginUpdateChecker\v5p7;
if ( !class_exists(Scheduler::class, false) ):
@@ -1,5 +1,5 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4;
namespace YahnisElsts\PluginUpdateChecker\v5p7;
if ( !class_exists(StateStore::class, false) ):
@@ -77,7 +77,7 @@ if ( !class_exists(StateStore::class, false) ):
* @param Update|null $update
* @return $this
*/
public function setUpdate(Update $update = null) {
public function setUpdate($update = null) {
$this->lazyLoad();
$this->update = $update;
return $this;
@@ -1,7 +1,7 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4\Theme;
namespace YahnisElsts\PluginUpdateChecker\v5p7\Theme;
use YahnisElsts\PluginUpdateChecker\v5p4\InstalledPackage;
use YahnisElsts\PluginUpdateChecker\v5p7\InstalledPackage;
if ( !class_exists(Package::class, false) ):
@@ -1,8 +1,8 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4\Theme;
namespace YahnisElsts\PluginUpdateChecker\v5p7\Theme;
use YahnisElsts\PluginUpdateChecker\v5p4\Update as BaseUpdate;
use YahnisElsts\PluginUpdateChecker\v5p7\Update as BaseUpdate;
if ( !class_exists(Update::class, false) ):
@@ -1,11 +1,11 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4\Theme;
namespace YahnisElsts\PluginUpdateChecker\v5p7\Theme;
use YahnisElsts\PluginUpdateChecker\v5p4\UpdateChecker as BaseUpdateChecker;
use YahnisElsts\PluginUpdateChecker\v5p4\InstalledPackage;
use YahnisElsts\PluginUpdateChecker\v5p4\Scheduler;
use YahnisElsts\PluginUpdateChecker\v5p4\DebugBar;
use YahnisElsts\PluginUpdateChecker\v5p7\UpdateChecker as BaseUpdateChecker;
use YahnisElsts\PluginUpdateChecker\v5p7\InstalledPackage;
use YahnisElsts\PluginUpdateChecker\v5p7\Scheduler;
use YahnisElsts\PluginUpdateChecker\v5p7\DebugBar;
if ( !class_exists(UpdateChecker::class, false) ):
@@ -1,5 +1,5 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4;
namespace YahnisElsts\PluginUpdateChecker\v5p7;
use stdClass;
@@ -1,5 +1,5 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4;
namespace YahnisElsts\PluginUpdateChecker\v5p7;
use stdClass;
use WP_Error;
@@ -171,6 +171,10 @@ if ( !class_exists(UpdateChecker::class, false) ):
//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();
@@ -192,6 +196,7 @@ if ( !class_exists(UpdateChecker::class, false) ):
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'));
@@ -266,6 +271,76 @@ if ( !class_exists(UpdateChecker::class, false) ):
*/
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.
*
@@ -384,7 +459,7 @@ if ( !class_exists(UpdateChecker::class, false) ):
*
* @param Metadata|null $update
*/
protected function fixSupportedWordpressVersion(Metadata $update = null) {
protected function fixSupportedWordpressVersion($update = null) {
if ( !isset($update->tested) || !preg_match('/^\d++\.\d++$/', $update->tested) ) {
return;
}
@@ -557,7 +632,15 @@ if ( !class_exists(UpdateChecker::class, false) ):
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);
$updates = $this->addUpdateToList($updates, $update->toWpFormat());
//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);
@@ -655,6 +738,38 @@ if ( !class_exists(UpdateChecker::class, false) ):
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
* -------------------------------------------------------------------
@@ -698,7 +813,7 @@ if ( !class_exists(UpdateChecker::class, false) ):
$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;
@@ -924,25 +1039,62 @@ if ( !class_exists(UpdateChecker::class, false) ):
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 ) {
//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 <span class="code">%s</span>, 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&#8230;',
'<span class="code">' . basename($source) . '</span>',
@@ -1,5 +1,5 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4;
namespace YahnisElsts\PluginUpdateChecker\v5p7;
if ( !class_exists(UpgraderStatus::class, false) ):
@@ -1,5 +1,5 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4;
namespace YahnisElsts\PluginUpdateChecker\v5p7;
if ( !class_exists(Utils::class, false) ):
@@ -1,6 +1,6 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4\Vcs;
namespace YahnisElsts\PluginUpdateChecker\v5p7\Vcs;
use Parsedown;
use PucReadmeParser;
@@ -51,6 +51,16 @@ if ( !class_exists(Api::class, false) ):
*/
protected $credentials = null;
/**
* @var string|null The value of the "Authorization" header for API requests.
*/
private $authorizationHeader = null;
/**
* @var string|null If set, add the "Authorization" header to update downloads that start with this prefix.
*/
private $downloadUrlPrefixRequiringAuth = 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".
@@ -322,6 +332,22 @@ if ( !class_exists(Api::class, false) ):
return null;
}
/**
* @return array
*/
protected function getApiRequestHttpOptions() {
$options = ['timeout' => wp_doing_cron() ? 10 : 3];
if ( $this->isAuthenticationEnabled() && !empty($this->authorizationHeader) ) {
$options['headers'] = ['Authorization' => $this->authorizationHeader];
}
if ( !empty($this->httpFilterName) ) {
$options = apply_filters($this->httpFilterName, $options);
}
return $options;
}
/**
* Set authentication credentials.
*
@@ -332,7 +358,130 @@ if ( !class_exists(Api::class, false) ):
}
public function isAuthenticationEnabled() {
return !empty($this->credentials);
return !empty($this->credentials) || !empty($this->authorizationHeader);
}
/**
* Get the value of the "Authorization" header for API requests, if any.
*
* @return string
*/
protected function getAuthorizationHeader() {
return $this->authorizationHeader ?: '';
}
/**
* Enable basic access authentication with the specified username and password.
*
* @param string $username
* @param string $password
* @param string|null $downloadUrlPrefix Optionally, add the same Authorization header to update
* downloads where the download URL starts with this prefix.
*/
protected function enableBasicAuth($username, $password, $downloadUrlPrefix = null) {
$this->authorizationHeader = 'Basic ' . base64_encode($username . ':' . $password);
$this->downloadUrlPrefixRequiringAuth = $downloadUrlPrefix;
if ( !empty($this->downloadUrlPrefixRequiringAuth) ) {
$this->enableDownloadRequestFilter();
}
}
/**
* @var bool Whether the hook that registers download request filter(s) has already been added.
*/
private $preDownloadHookAdded = false;
/**
* Enable the hooks that let you filter HTTP requests for update downloads.
*/
protected function enableDownloadRequestFilter() {
if ( $this->preDownloadHookAdded ) {
return;
}
$this->preDownloadHookAdded = true;
//Optimization: Instead of filtering all HTTP requests, let's do it only when
//WordPress is about to download an update. So this is a two-step process;
//the actual request arg filter is added in the following hook.
add_filter('upgrader_pre_download', [$this, 'addDownloadRequestFilters']); //WP 3.7+
}
/**
* @var bool
*/
private $downloadFiltersAdded = false;
/**
* @internal
* @param bool $result Pass-through value for the "upgrader_pre_download" filter. Ignored by this callback.
* @return bool
*/
public function addDownloadRequestFilters($result) {
if ( !$this->downloadFiltersAdded ) {
$this->downloadFiltersAdded = true;
//phpcs:ignore WordPressVIPMinimum.Hooks.RestrictedHooks.http_request_args -- The callback doesn't change the timeout.
add_filter('http_request_args', [$this, 'filterUpdateDownloadRequestArgs'], 10, 2);
$authorizationHeader = $this->getAuthorizationHeader();
if ( $this->isAuthenticationEnabled() && !empty($authorizationHeader) ) {
add_action('requests-requests.before_redirect', [$this, 'removeAuthHeaderFromRedirects'], 10, 2);
}
}
return $result;
}
/**
* Filter request arguments/options for update downloads.
*
* Note that this callback can potentially be called for *any* update download. You still
* need to verify that the URL is one of yours.
*
* @internal
* @param array $requestArgs
* @param string $url
* @return array
*/
public function filterUpdateDownloadRequestArgs($requestArgs, $url = '') {
//Add an authorization header to our downloads if needed.
$authHeader = $this->getAuthorizationHeader();
if (
$this->isAuthenticationEnabled()
&& !empty($authHeader)
&& !empty($this->downloadUrlPrefixRequiringAuth)
&& ((strpos($url, $this->downloadUrlPrefixRequiringAuth)) === 0)
) {
$requestArgs['headers']['Authorization'] = $authHeader;
}
return $requestArgs;
}
/**
* At least in older WP versions, when following a redirect, the Requests library will
* automatically forward the Authorization header to other hosts. We don't want that
* because it breaks AWS downloads and can leak authorization information.
*
* @param string $location
* @param array $headers
* @internal
*/
public function removeAuthHeaderFromRedirects(&$location, &$headers) {
if (
//If there's no download URL prefix configured, we would not have added an auth header,
//and there's also no way to check if this URL needs auth or not.
empty($this->downloadUrlPrefixRequiringAuth)
//If this request goes to our download URL, we can leave the header.
|| ((strpos($location, $this->downloadUrlPrefixRequiringAuth)) === 0)
) {
return;
}
//Remove the header.
$authorizationHeader = $this->getAuthorizationHeader();
if ( isset($headers['Authorization']) && ($headers['Authorization'] === $authorizationHeader) ) {
unset($headers['Authorization']);
}
}
/**
@@ -1,5 +1,5 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4\Vcs;
namespace YahnisElsts\PluginUpdateChecker\v5p7\Vcs;
if ( !interface_exists(BaseChecker::class, false) ):
@@ -1,18 +1,12 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4\Vcs;
namespace YahnisElsts\PluginUpdateChecker\v5p7\Vcs;
use YahnisElsts\PluginUpdateChecker\v5p4\OAuthSignature;
use YahnisElsts\PluginUpdateChecker\v5p4\Utils;
use YahnisElsts\PluginUpdateChecker\v5p7\Utils;
if ( !class_exists(BitBucketApi::class, false) ):
class BitBucketApi extends Api {
/**
* @var OAuthSignature
*/
private $oauth = null;
/**
* @var string
*/
@@ -148,11 +142,19 @@ if ( !class_exists(BitBucketApi::class, false) ):
* @return string
*/
protected function getDownloadUrl($ref) {
return $this->getDownloadBaseUrl() . $ref . '.zip';
}
/**
* Get the base URL for ZIP downloads from our repo. Includes the trailing slash.
*
* @return string
*/
protected function getDownloadBaseUrl() {
return sprintf(
'https://bitbucket.org/%s/%s/get/%s.zip',
'https://bitbucket.org/%s/%s/get/',
$this->username,
$this->repository,
$ref
$this->repository
);
}
@@ -206,15 +208,7 @@ if ( !class_exists(BitBucketApi::class, false) ):
));
$baseUrl = $url;
if ( $this->oauth ) {
$url = $this->oauth->sign($url,'GET');
}
$options = array('timeout' => wp_doing_cron() ? 10 : 3);
if ( !empty($this->httpFilterName) ) {
$options = apply_filters($this->httpFilterName, $options);
}
$response = wp_remote_get($url, $options);
$response = wp_remote_get($url, $this->getApiRequestHttpOptions());
if ( is_wp_error($response) ) {
do_action('puc_api_error', $response, null, $url, $this->slug);
return $response;
@@ -248,25 +242,18 @@ if ( !class_exists(BitBucketApi::class, false) ):
public function setAuthentication($credentials) {
parent::setAuthentication($credentials);
if ( !empty($credentials) && !empty($credentials['consumer_key']) ) {
$this->oauth = new OAuthSignature(
$credentials['consumer_key'],
$credentials['consumer_secret']
if (
is_array($credentials)
&& !empty($credentials['username'])
&& !empty($credentials['api_token'])
) {
$this->enableBasicAuth(
$credentials['username'],
$credentials['api_token'],
$this->getDownloadBaseUrl()
);
} 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;
@@ -1,6 +1,6 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4\Vcs;
namespace YahnisElsts\PluginUpdateChecker\v5p7\Vcs;
use Parsedown;
@@ -29,11 +29,6 @@ if ( !class_exists(GitHubApi::class, false) ):
*/
protected $accessToken;
/**
* @var bool
*/
private $downloadFilterAdded = false;
public function __construct($repositoryUrl, $accessToken = null) {
$path = wp_parse_url($repositoryUrl, PHP_URL_PATH);
if ( preg_match('@^/?(?P<username>[^/]+?)/(?P<repository>[^/#?&]+?)/?$@', $path, $matches) ) {
@@ -248,15 +243,7 @@ if ( !class_exists(GitHubApi::class, false) ):
$baseUrl = $url;
$url = $this->buildApiUrl($url, $queryParams);
$options = array('timeout' => wp_doing_cron() ? 10 : 3);
if ( $this->isAuthenticationEnabled() ) {
$options['headers'] = array('Authorization' => $this->getAuthorizationHeader());
}
if ( !empty($this->httpFilterName) ) {
$options = apply_filters($this->httpFilterName, $options);
}
$response = wp_remote_get($url, $options);
$response = wp_remote_get($url, $this->getApiRequestHttpOptions());
if ( is_wp_error($response) ) {
do_action('puc_api_error', $response, null, $url, $this->slug);
return $response;
@@ -348,11 +335,17 @@ if ( !class_exists(GitHubApi::class, false) ):
public function setAuthentication($credentials) {
parent::setAuthentication($credentials);
//This property is no longer used internally, but is kept for backwards compatibility.
$this->accessToken = is_string($credentials) ? $credentials : null;
//Optimization: Instead of filtering all HTTP requests, let's do it only when
//WordPress is about to download an update.
add_filter('upgrader_pre_download', array($this, 'addHttpRequestFilter'), 10, 1); //WP 3.7+
if ( is_string($credentials) && !empty($credentials) ) {
$repoApiBaseUrl = $this->buildApiUrl('/repos/:user/:repo/', []);
$this->enableBasicAuth($this->userName, $credentials, $repoApiBaseUrl);
}
//Assets sometimes need an Accept header, so we add a request filter even if basic
//authentication isn't enabled.
$this->enableDownloadRequestFilter();
}
protected function getUpdateDetectionStrategies($configBranch) {
@@ -393,74 +386,15 @@ if ( !class_exists(GitHubApi::class, false) ):
return null;
}
/**
* @param bool $result
* @return bool
* @internal
*/
public function addHttpRequestFilter($result) {
if ( !$this->downloadFilterAdded && $this->isAuthenticationEnabled() ) {
//phpcs:ignore WordPressVIPMinimum.Hooks.RestrictedHooks.http_request_args -- The callback doesn't change the timeout.
add_filter('http_request_args', array($this, 'setUpdateDownloadHeaders'), 10, 2);
add_action('requests-requests.before_redirect', array($this, 'removeAuthHeaderFromRedirects'), 10, 4);
$this->downloadFilterAdded = true;
}
return $result;
}
/**
* Set the HTTP headers that are necessary to download updates from private repositories.
*
* See GitHub docs:
*
* @link https://developer.github.com/v3/repos/releases/#get-a-single-release-asset
* @link https://developer.github.com/v3/auth/#basic-authentication
*
* @internal
* @param array $requestArgs
* @param string $url
* @return array
*/
public function setUpdateDownloadHeaders($requestArgs, $url = '') {
public function filterUpdateDownloadRequestArgs($requestArgs, $url = '') {
//Release assets need an "Accept" header. See GitHub Docs:
//https://developer.github.com/v3/repos/releases/#get-a-single-release-asset
//Is WordPress trying to download one of our release assets?
if ( $this->releaseAssetsEnabled && (strpos($url, $this->getAssetApiBaseUrl()) !== false) ) {
$requestArgs['headers']['Accept'] = 'application/octet-stream';
}
//Use Basic authentication, but only if the download is from our repository.
$repoApiBaseUrl = $this->buildApiUrl('/repos/:user/:repo/', array());
if ( $this->isAuthenticationEnabled() && (strpos($url, $repoApiBaseUrl)) === 0 ) {
$requestArgs['headers']['Authorization'] = $this->getAuthorizationHeader();
}
return $requestArgs;
}
/**
* When following a redirect, the Requests library will automatically forward
* the authorization header to other hosts. We don't want that because it breaks
* AWS downloads and can leak authorization information.
*
* @param string $location
* @param array $headers
* @internal
*/
public function removeAuthHeaderFromRedirects(&$location, &$headers) {
$repoApiBaseUrl = $this->buildApiUrl('/repos/:user/:repo/', array());
if ( strpos($location, $repoApiBaseUrl) === 0 ) {
return; //This request is going to GitHub, so it's fine.
}
//Remove the header.
if ( isset($headers['Authorization']) ) {
unset($headers['Authorization']);
}
}
/**
* Generate the value of the "Authorization" header.
*
* @return string
*/
protected function getAuthorizationHeader() {
return 'Basic ' . base64_encode($this->userName . ':' . $this->accessToken);
return parent::filterUpdateDownloadRequestArgs($requestArgs, $url);
}
}
@@ -1,6 +1,6 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4\Vcs;
namespace YahnisElsts\PluginUpdateChecker\v5p7\Vcs;
if ( !class_exists(GitLabApi::class, false) ):
@@ -260,12 +260,7 @@ if ( !class_exists(GitLabApi::class, false) ):
$baseUrl = $url;
$url = $this->buildApiUrl($url, $queryParams);
$options = array('timeout' => wp_doing_cron() ? 10 : 3);
if ( !empty($this->httpFilterName) ) {
$options = apply_filters($this->httpFilterName, $options);
}
$response = wp_remote_get($url, $options);
$response = wp_remote_get($url, $this->getApiRequestHttpOptions());
if ( is_wp_error($response) ) {
do_action('puc_api_error', $response, null, $url, $this->slug);
return $response;
@@ -1,8 +1,8 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4\Vcs;
namespace YahnisElsts\PluginUpdateChecker\v5p7\Vcs;
use YahnisElsts\PluginUpdateChecker\v5p4\Plugin;
use YahnisElsts\PluginUpdateChecker\v5p7\Plugin;
if ( !class_exists(PluginUpdateChecker::class, false) ):
@@ -1,5 +1,5 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4\Vcs;
namespace YahnisElsts\PluginUpdateChecker\v5p7\Vcs;
if ( !class_exists(Reference::class, false) ):
@@ -1,6 +1,6 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4\Vcs;
namespace YahnisElsts\PluginUpdateChecker\v5p7\Vcs;
if ( !trait_exists(ReleaseAssetSupport::class, false) ) :
@@ -1,6 +1,6 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4\Vcs;
namespace YahnisElsts\PluginUpdateChecker\v5p7\Vcs;
if ( !trait_exists(ReleaseFilteringFeature::class, false) ) :
@@ -1,9 +1,9 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4\Vcs;
namespace YahnisElsts\PluginUpdateChecker\v5p7\Vcs;
use YahnisElsts\PluginUpdateChecker\v5p4\Theme;
use YahnisElsts\PluginUpdateChecker\v5p4\Utils;
use YahnisElsts\PluginUpdateChecker\v5p7\Theme;
use YahnisElsts\PluginUpdateChecker\v5p7\Utils;
if ( !class_exists(ThemeUpdateChecker::class, false) ):
@@ -1,6 +1,6 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4\Vcs;
namespace YahnisElsts\PluginUpdateChecker\v5p7\Vcs;
if ( !trait_exists(VcsCheckerMethods::class, false) ) :
@@ -1,6 +1,6 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4;
namespace YahnisElsts\PluginUpdateChecker\v5p7;
use WP_CLI;