源码提交

This commit is contained in:
文派备案 2025-03-10 12:26:57 +08:00
parent 2955d0dde5
commit 3fd3701567
131 changed files with 13972 additions and 0 deletions

View file

@ -0,0 +1,379 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p3\Vcs;
use Parsedown;
use PucReadmeParser;
if ( !class_exists(Api::class, false) ):
abstract class Api {
const STRATEGY_LATEST_RELEASE = 'latest_release';
const STRATEGY_LATEST_TAG = 'latest_tag';
const STRATEGY_STABLE_TAG = 'stable_tag';
const STRATEGY_BRANCH = 'branch';
/**
* Consider all releases regardless of their version number or prerelease/upcoming
* release status.
*/
const RELEASE_FILTER_ALL = 3;
/**
* Exclude releases that have the "prerelease" or "upcoming release" flag.
*
* This does *not* look for prerelease keywords like "beta" in the version number.
* It only uses the data provided by the API. For example, on GitHub, you can
* manually mark a release as a prerelease.
*/
const RELEASE_FILTER_SKIP_PRERELEASE = 1;
/**
* If there are no release assets or none of them match the configured filter,
* fall back to the automatically generated source code archive.
*/
const PREFER_RELEASE_ASSETS = 1;
/**
* Skip releases that don't have any matching release assets.
*/
const REQUIRE_RELEASE_ASSETS = 2;
protected $tagNameProperty = 'name';
protected $slug = '';
/**
* @var string
*/
protected $repositoryUrl = '';
/**
* @var mixed Authentication details for private repositories. Format depends on service.
*/
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 = '';
/**
* @var string The filter applied to the list of update detection strategies that
* are used to find the latest version.
*/
protected $strategyFilterName = '';
/**
* @var string|null
*/
protected $localDirectory = null;
/**
* Api constructor.
*
* @param string $repositoryUrl
* @param array|string|null $credentials
*/
public function __construct($repositoryUrl, $credentials = null) {
$this->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.
* @return null|Reference
*/
public function chooseReference($configBranch) {
$strategies = $this->getUpdateDetectionStrategies($configBranch);
if ( !empty($this->strategyFilterName) ) {
$strategies = apply_filters(
$this->strategyFilterName,
$strategies,
$this->slug
);
}
foreach ($strategies as $strategy) {
$reference = call_user_func($strategy);
if ( !empty($reference) ) {
return $reference;
}
}
return null;
}
/**
* Get an ordered list of strategies that can be used to find the latest version.
*
* The update checker will try each strategy in order until one of them
* returns a valid reference.
*
* @param string $configBranch
* @return array<callable> Array of callables that return Vcs_Reference objects.
*/
abstract protected function getUpdateDetectionStrategies($configBranch);
/**
* Get the readme.txt file from the remote repository and parse it
* according to the plugin readme standard.
*
* @param string $ref Tag or branch name.
* @return array Parsed readme.
*/
public function getRemoteReadme($ref = 'master') {
$fileContents = $this->getRemoteFile($this->getLocalReadmeName(), $ref);
if ( empty($fileContents) ) {
return array();
}
$parser = new PucReadmeParser();
return $parser->parse_readme_contents($fileContents);
}
/**
* Get the case-sensitive name of the local readme.txt file.
*
* In most cases it should just be called "readme.txt", but some plugins call it "README.txt",
* "README.TXT", or even "Readme.txt". Most VCS are case-sensitive so we need to know the correct
* capitalization.
*
* Defaults to "readme.txt" (all lowercase).
*
* @return string
*/
public function getLocalReadmeName() {
static $fileName = null;
if ( $fileName !== null ) {
return $fileName;
}
$fileName = 'readme.txt';
if ( isset($this->localDirectory) ) {
$files = scandir($this->localDirectory);
if ( !empty($files) ) {
foreach ($files as $possibleFileName) {
if ( strcasecmp($possibleFileName, 'readme.txt') === 0 ) {
$fileName = $possibleFileName;
break;
}
}
}
}
return $fileName;
}
/**
* Get a branch.
*
* @param string $branchName
* @return Reference|null
*/
abstract public function getBranch($branchName);
/**
* Get a specific tag.
*
* @param string $tagName
* @return Reference|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 Reference|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(substr($name, 0, 1)) ) {
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);
}
/**
* 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.
*
* @param \stdClass $tag1 Tag object.
* @param \stdClass $tag2 Another tag object.
* @return int
*/
protected function compareTagNames($tag1, $tag2) {
$property = $this->tagNameProperty;
if ( !isset($tag1->$property) ) {
return 1;
}
if ( !isset($tag2->$property) ) {
return -1;
}
return -version_compare(ltrim($tag1->$property, 'v'), ltrim($tag2->$property, '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 = null) {
if ( !isset($directory) ) {
$directory = $this->localDirectory;
}
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;
}
/**
* Set authentication credentials.
*
* @param $credentials
*/
public function setAuthentication($credentials) {
$this->credentials = $credentials;
}
public function isAuthenticationEnabled() {
return !empty($this->credentials);
}
/**
* @param string $url
* @return string
*/
public function signDownloadUrl($url) {
return $url;
}
/**
* @param string $filterName
*/
public function setHttpFilterName($filterName) {
$this->httpFilterName = $filterName;
}
/**
* @param string $filterName
*/
public function setStrategyFilterName($filterName) {
$this->strategyFilterName = $filterName;
}
/**
* @param string $directory
*/
public function setLocalDirectory($directory) {
if ( empty($directory) || !is_dir($directory) || ($directory === '.') ) {
$this->localDirectory = null;
} else {
$this->localDirectory = $directory;
}
}
/**
* @param string $slug
*/
public function setSlug($slug) {
$this->slug = $slug;
}
}
endif;

View file

@ -0,0 +1,29 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p3\Vcs;
if ( !interface_exists(BaseChecker::class, false) ):
interface BaseChecker {
/**
* Set the repository branch to use for updates. Defaults to 'master'.
*
* @param string $branch
* @return $this
*/
public function setBranch($branch);
/**
* Set authentication credentials.
*
* @param array|string $credentials
* @return $this
*/
public function setAuthentication($credentials);
/**
* @return Api
*/
public function getVcsApi();
}
endif;

View file

@ -0,0 +1,272 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p3\Vcs;
use YahnisElsts\PluginUpdateChecker\v5p3\OAuthSignature;
use YahnisElsts\PluginUpdateChecker\v5p3\Utils;
if ( !class_exists(BitBucketApi::class, false) ):
class BitBucketApi extends Api {
/**
* @var OAuthSignature
*/
private $oauth = null;
/**
* @var string
*/
private $username;
/**
* @var string
*/
private $repository;
public function __construct($repositoryUrl, $credentials = array()) {
$path = wp_parse_url($repositoryUrl, PHP_URL_PATH);
if ( preg_match('@^/?(?P<username>[^/]+?)/(?P<repository>[^/#?&]+?)/?$@', $path, $matches) ) {
$this->username = $matches['username'];
$this->repository = $matches['repository'];
} else {
throw new \InvalidArgumentException('Invalid BitBucket repository URL: "' . $repositoryUrl . '"');
}
parent::__construct($repositoryUrl, $credentials);
}
protected function getUpdateDetectionStrategies($configBranch) {
$strategies = array(
self::STRATEGY_STABLE_TAG => function () use ($configBranch) {
return $this->getStableTag($configBranch);
},
);
if ( ($configBranch === 'master' || $configBranch === 'main') ) {
$strategies[self::STRATEGY_LATEST_TAG] = array($this, 'getLatestTag');
}
$strategies[self::STRATEGY_BRANCH] = function () use ($configBranch) {
return $this->getBranch($configBranch);
};
return $strategies;
}
public function getBranch($branchName) {
$branch = $this->api('/refs/branches/' . $branchName);
if ( is_wp_error($branch) || empty($branch) ) {
return null;
}
//The "/src/{stuff}/{path}" endpoint doesn't seem to handle branch names that contain slashes.
//If we don't encode the slash, we get a 404. If we encode it as "%2F", we get a 401.
//To avoid issues, if the branch name is not URL-safe, let's use the commit hash instead.
$ref = $branch->name;
if ((urlencode($ref) !== $ref) && isset($branch->target->hash)) {
$ref = $branch->target->hash;
}
return new Reference(array(
'name' => $ref,
'updated' => $branch->target->date,
'downloadUrl' => $this->getDownloadUrl($branch->name),
));
}
/**
* Get a specific tag.
*
* @param string $tagName
* @return Reference|null
*/
public function getTag($tagName) {
$tag = $this->api('/refs/tags/' . $tagName);
if ( is_wp_error($tag) || empty($tag) ) {
return null;
}
return new Reference(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 Reference|null
*/
public function getLatestTag() {
$tags = $this->api('/refs/tags?sort=-target.date');
if ( !isset($tags, $tags->values) || !is_array($tags->values) ) {
return null;
}
//Filter and sort the list of tags.
$versionTags = $this->sortTagsByVersion($tags->values);
//Return the first result.
if ( !empty($versionTags) ) {
$tag = $versionTags[0];
return new Reference(array(
'name' => $tag->name,
'version' => ltrim($tag->name, 'v'),
'updated' => $tag->target->date,
'downloadUrl' => $this->getDownloadUrl($tag->name),
));
}
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|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
*/
protected function getDownloadUrl($ref) {
return sprintf(
'https://bitbucket.org/%s/%s/get/%s.zip',
$this->username,
$this->repository,
$ref
);
}
/**
* 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') {
$response = $this->api('src/' . $ref . '/' . ltrim($path));
if ( is_wp_error($response) || !is_string($response) ) {
return null;
}
return $response;
}
/**
* 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) {
$response = $this->api('commits/' . $ref);
if ( isset($response->values, $response->values[0], $response->values[0]->date) ) {
return $response->values[0]->date;
}
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') {
$url = ltrim($url, '/');
$isSrcResource = Utils::startsWith($url, 'src/');
$url = implode('/', array(
'https://api.bitbucket.org',
$version,
'repositories',
$this->username,
$this->repository,
$url
));
$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);
if ( is_wp_error($response) ) {
do_action('puc_api_error', $response, null, $url, $this->slug);
return $response;
}
$code = wp_remote_retrieve_response_code($response);
$body = wp_remote_retrieve_body($response);
if ( $code === 200 ) {
if ( $isSrcResource ) {
//Most responses are JSON-encoded, but src resources just
//return raw file contents.
$document = $body;
} else {
$document = json_decode($body);
}
return $document;
}
$error = new \WP_Error(
'puc-bitbucket-http-error',
sprintf('BitBucket API error. Base URL: "%s", HTTP status code: %d.', $baseUrl, $code)
);
do_action('puc_api_error', $error, $response, $url, $this->slug);
return $error;
}
/**
* @param array $credentials
*/
public function setAuthentication($credentials) {
parent::setAuthentication($credentials);
if ( !empty($credentials) && !empty($credentials['consumer_key']) ) {
$this->oauth = new 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;

View file

@ -0,0 +1,467 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p3\Vcs;
use Parsedown;
if ( !class_exists(GitHubApi::class, false) ):
class GitHubApi extends Api {
use ReleaseAssetSupport;
use ReleaseFilteringFeature;
/**
* @var string GitHub username.
*/
protected $userName;
/**
* @var string GitHub repository name.
*/
protected $repositoryName;
/**
* @var string Either a fully qualified repository URL, or just "user/repo-name".
*/
protected $repositoryUrl;
/**
* @var string GitHub authentication token. Optional.
*/
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) ) {
$this->userName = $matches['username'];
$this->repositoryName = $matches['repository'];
} else {
throw new \InvalidArgumentException('Invalid GitHub repository URL: "' . $repositoryUrl . '"');
}
parent::__construct($repositoryUrl, $accessToken);
}
/**
* Get the latest release from GitHub.
*
* @return Reference|null
*/
public function getLatestRelease() {
//The "latest release" endpoint returns one release and always skips pre-releases,
//so we can only use it if that's compatible with the current filter settings.
if (
$this->shouldSkipPreReleases()
&& (
($this->releaseFilterMaxReleases === 1) || !$this->hasCustomReleaseFilter()
)
) {
//Just get the latest release.
$release = $this->api('/repos/:user/:repo/releases/latest');
if ( is_wp_error($release) || !is_object($release) || !isset($release->tag_name) ) {
return null;
}
$foundReleases = array($release);
} else {
//Get a list of the most recent releases.
$foundReleases = $this->api(
'/repos/:user/:repo/releases',
array('per_page' => $this->releaseFilterMaxReleases)
);
if ( is_wp_error($foundReleases) || !is_array($foundReleases) ) {
return null;
}
}
foreach ($foundReleases as $release) {
//Always skip drafts.
if ( isset($release->draft) && !empty($release->draft) ) {
continue;
}
//Skip pre-releases unless specifically included.
if (
$this->shouldSkipPreReleases()
&& isset($release->prerelease)
&& !empty($release->prerelease)
) {
continue;
}
$versionNumber = ltrim($release->tag_name, 'v'); //Remove the "v" prefix from "v1.2.3".
//Custom release filtering.
if ( !$this->matchesCustomReleaseFilter($versionNumber, $release) ) {
continue;
}
$reference = new Reference(array(
'name' => $release->tag_name,
'version' => $versionNumber,
'downloadUrl' => $release->zipball_url,
'updated' => $release->created_at,
'apiResponse' => $release,
));
if ( isset($release->assets[0]) ) {
$reference->downloadCount = $release->assets[0]->download_count;
}
if ( $this->releaseAssetsEnabled ) {
//Use the first release asset that matches the specified regular expression.
if ( isset($release->assets, $release->assets[0]) ) {
$matchingAssets = array_values(array_filter($release->assets, array($this, 'matchesAssetFilter')));
} else {
$matchingAssets = array();
}
if ( !empty($matchingAssets) ) {
if ( $this->isAuthenticationEnabled() ) {
/**
* Keep in mind that we'll need to add an "Accept" header to download this asset.
*
* @see setUpdateDownloadHeaders()
*/
$reference->downloadUrl = $matchingAssets[0]->url;
} else {
//It seems that browser_download_url only works for public repositories.
//Using an access_token doesn't help. Maybe OAuth would work?
$reference->downloadUrl = $matchingAssets[0]->browser_download_url;
}
$reference->downloadCount = $matchingAssets[0]->download_count;
} else if ( $this->releaseAssetPreference === Api::REQUIRE_RELEASE_ASSETS ) {
//None of the assets match the filter, and we're not allowed
//to fall back to the auto-generated source ZIP.
return null;
}
}
if ( !empty($release->body) ) {
$reference->changelog = Parsedown::instance()->text($release->body);
}
return $reference;
}
return null;
}
/**
* Get the tag that looks like the highest version number.
*
* @return Reference|null
*/
public function getLatestTag() {
$tags = $this->api('/repos/:user/:repo/tags');
if ( is_wp_error($tags) || !is_array($tags) ) {
return null;
}
$versionTags = $this->sortTagsByVersion($tags);
if ( empty($versionTags) ) {
return null;
}
$tag = $versionTags[0];
return new Reference(array(
'name' => $tag->name,
'version' => ltrim($tag->name, 'v'),
'downloadUrl' => $tag->zipball_url,
'apiResponse' => $tag,
));
}
/**
* Get a branch by name.
*
* @param string $branchName
* @return null|Reference
*/
public function getBranch($branchName) {
$branch = $this->api('/repos/:user/:repo/branches/' . $branchName);
if ( is_wp_error($branch) || empty($branch) ) {
return null;
}
$reference = new Reference(array(
'name' => $branch->name,
'downloadUrl' => $this->buildArchiveDownloadUrl($branch->name),
'apiResponse' => $branch,
));
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.
*
* @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) && isset($commits[0]) ) {
return $commits[0];
}
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) && isset($commits[0]) ) {
return $commits[0]->commit->author->date;
}
return null;
}
/**
* Perform a GitHub API request.
*
* @param string $url
* @param array $queryParams
* @return mixed|\WP_Error
*/
protected function api($url, $queryParams = array()) {
$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);
if ( is_wp_error($response) ) {
do_action('puc_api_error', $response, null, $url, $this->slug);
return $response;
}
$code = wp_remote_retrieve_response_code($response);
$body = wp_remote_retrieve_body($response);
if ( $code === 200 ) {
$document = json_decode($body);
return $document;
}
$error = new \WP_Error(
'puc-github-http-error',
sprintf('GitHub API error. Base URL: "%s", HTTP status code: %d.', $baseUrl, $code)
);
do_action('puc_api_error', $error, $response, $url, $this->slug);
return $error;
}
/**
* Build a fully qualified URL for an API request.
*
* @param string $url
* @param array $queryParams
* @return string
*/
protected function buildApiUrl($url, $queryParams) {
$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($queryParams) ) {
$url = add_query_arg($queryParams, $url);
}
return $url;
}
/**
* 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)
);
return $url;
}
/**
* Get a specific tag.
*
* @param string $tagName
* @return void
*/
public function getTag($tagName) {
//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;
//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+
}
protected function getUpdateDetectionStrategies($configBranch) {
$strategies = array();
if ( $configBranch === 'master' || $configBranch === 'main') {
//Use the latest release.
$strategies[self::STRATEGY_LATEST_RELEASE] = array($this, 'getLatestRelease');
//Failing that, use the tag with the highest version number.
$strategies[self::STRATEGY_LATEST_TAG] = array($this, 'getLatestTag');
}
//Alternatively, just use the branch itself.
$strategies[self::STRATEGY_BRANCH] = function () use ($configBranch) {
return $this->getBranch($configBranch);
};
return $strategies;
}
/**
* Get the unchanging part of a release asset URL. Used to identify download attempts.
*
* @return string
*/
protected function getAssetApiBaseUrl() {
return sprintf(
'//api.github.com/repos/%1$s/%2$s/releases/assets/',
$this->userName,
$this->repositoryName
);
}
protected function getFilterableAssetName($releaseAsset) {
if ( isset($releaseAsset->name) ) {
return $releaseAsset->name;
}
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 = '') {
//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);
}
}
endif;

View file

@ -0,0 +1,414 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p3\Vcs;
if ( !class_exists(GitLabApi::class, false) ):
class GitLabApi extends Api {
use ReleaseAssetSupport;
use ReleaseFilteringFeature;
/**
* @var string GitLab username.
*/
protected $userName;
/**
* @var string GitLab server host.
*/
protected $repositoryHost;
/**
* @var string Protocol used by this GitLab server: "http" or "https".
*/
protected $repositoryProtocol = 'https';
/**
* @var string GitLab repository name.
*/
protected $repositoryName;
/**
* @var string GitLab authentication token. Optional.
*/
protected $accessToken;
/**
* @deprecated
* @var bool No longer used.
*/
protected $releasePackageEnabled = false;
public function __construct($repositoryUrl, $accessToken = null, $subgroup = null) {
//Parse the repository host to support custom hosts.
$port = wp_parse_url($repositoryUrl, PHP_URL_PORT);
if ( !empty($port) ) {
$port = ':' . $port;
}
$this->repositoryHost = wp_parse_url($repositoryUrl, PHP_URL_HOST) . $port;
if ( $this->repositoryHost !== 'gitlab.com' ) {
$this->repositoryProtocol = wp_parse_url($repositoryUrl, PHP_URL_SCHEME);
}
//Find the repository information
$path = wp_parse_url($repositoryUrl, PHP_URL_PATH);
if ( preg_match('@^/?(?P<username>[^/]+?)/(?P<repository>[^/#?&]+?)/?$@', $path, $matches) ) {
$this->userName = $matches['username'];
$this->repositoryName = $matches['repository'];
} elseif ( ($this->repositoryHost === 'gitlab.com') ) {
//This is probably a repository in a subgroup, e.g. "/organization/category/repo".
$parts = explode('/', trim($path, '/'));
if ( count($parts) < 3 ) {
throw new \InvalidArgumentException('Invalid GitLab.com repository URL: "' . $repositoryUrl . '"');
}
$lastPart = array_pop($parts);
$this->userName = implode('/', $parts);
$this->repositoryName = $lastPart;
} else {
//There could be subgroups in the URL: gitlab.domain.com/group/subgroup/subgroup2/repository
if ( $subgroup !== null ) {
$path = str_replace(trailingslashit($subgroup), '', $path);
}
//This is not a traditional url, it could be gitlab is in a deeper subdirectory.
//Get the path segments.
$segments = explode('/', untrailingslashit(ltrim($path, '/')));
//We need at least /user-name/repository-name/
if ( count($segments) < 2 ) {
throw new \InvalidArgumentException('Invalid GitLab repository URL: "' . $repositoryUrl . '"');
}
//Get the username and repository name.
$usernameRepo = array_splice($segments, -2, 2);
$this->userName = $usernameRepo[0];
$this->repositoryName = $usernameRepo[1];
//Append the remaining segments to the host if there are segments left.
if ( count($segments) > 0 ) {
$this->repositoryHost = trailingslashit($this->repositoryHost) . implode('/', $segments);
}
//Add subgroups to username.
if ( $subgroup !== null ) {
$this->userName = $usernameRepo[0] . '/' . untrailingslashit($subgroup);
}
}
parent::__construct($repositoryUrl, $accessToken);
}
/**
* Get the latest release from GitLab.
*
* @return Reference|null
*/
public function getLatestRelease() {
$releases = $this->api('/:id/releases', array('per_page' => $this->releaseFilterMaxReleases));
if ( is_wp_error($releases) || empty($releases) || !is_array($releases) ) {
return null;
}
foreach ($releases as $release) {
if (
//Skip invalid/unsupported releases.
!is_object($release)
|| !isset($release->tag_name)
//Skip upcoming releases.
|| (
!empty($release->upcoming_release)
&& $this->shouldSkipPreReleases()
)
) {
continue;
}
$versionNumber = ltrim($release->tag_name, 'v'); //Remove the "v" prefix from "v1.2.3".
//Apply custom filters.
if ( !$this->matchesCustomReleaseFilter($versionNumber, $release) ) {
continue;
}
$downloadUrl = $this->findReleaseDownloadUrl($release);
if ( empty($downloadUrl) ) {
//The latest release doesn't have valid download URL.
return null;
}
if ( !empty($this->accessToken) ) {
$downloadUrl = add_query_arg('private_token', $this->accessToken, $downloadUrl);
}
return new Reference(array(
'name' => $release->tag_name,
'version' => $versionNumber,
'downloadUrl' => $downloadUrl,
'updated' => $release->released_at,
'apiResponse' => $release,
));
}
return null;
}
/**
* @param object $release
* @return string|null
*/
protected function findReleaseDownloadUrl($release) {
if ( $this->releaseAssetsEnabled ) {
if ( isset($release->assets, $release->assets->links) ) {
//Use the first asset link where the URL matches the filter.
foreach ($release->assets->links as $link) {
if ( $this->matchesAssetFilter($link) ) {
return $link->url;
}
}
}
if ( $this->releaseAssetPreference === Api::REQUIRE_RELEASE_ASSETS ) {
//Falling back to source archives is not allowed, so give up.
return null;
}
}
//Use the first source code archive that's in ZIP format.
foreach ($release->assets->sources as $source) {
if ( isset($source->format) && ($source->format === 'zip') ) {
return $source->url;
}
}
return null;
}
/**
* Get the tag that looks like the highest version number.
*
* @return Reference|null
*/
public function getLatestTag() {
$tags = $this->api('/:id/repository/tags');
if ( is_wp_error($tags) || empty($tags) || !is_array($tags) ) {
return null;
}
$versionTags = $this->sortTagsByVersion($tags);
if ( empty($versionTags) ) {
return null;
}
$tag = $versionTags[0];
return new Reference(array(
'name' => $tag->name,
'version' => ltrim($tag->name, 'v'),
'downloadUrl' => $this->buildArchiveDownloadUrl($tag->name),
'apiResponse' => $tag,
));
}
/**
* Get a branch by name.
*
* @param string $branchName
* @return null|Reference
*/
public function getBranch($branchName) {
$branch = $this->api('/:id/repository/branches/' . $branchName);
if ( is_wp_error($branch) || empty($branch) ) {
return null;
}
$reference = new Reference(array(
'name' => $branch->name,
'downloadUrl' => $this->buildArchiveDownloadUrl($branch->name),
'apiResponse' => $branch,
));
if ( isset($branch->commit, $branch->commit->committed_date) ) {
$reference->updated = $branch->commit->committed_date;
}
return $reference;
}
/**
* 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('/:id/repository/commits/', array('ref_name' => $ref));
if ( is_wp_error($commits) || !is_array($commits) || !isset($commits[0]) ) {
return null;
}
return $commits[0]->committed_date;
}
/**
* Perform a GitLab API request.
*
* @param string $url
* @param array $queryParams
* @return mixed|\WP_Error
*/
protected function api($url, $queryParams = array()) {
$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);
if ( is_wp_error($response) ) {
do_action('puc_api_error', $response, null, $url, $this->slug);
return $response;
}
$code = wp_remote_retrieve_response_code($response);
$body = wp_remote_retrieve_body($response);
if ( $code === 200 ) {
return json_decode($body);
}
$error = new \WP_Error(
'puc-gitlab-http-error',
sprintf('GitLab API error. URL: "%s", HTTP status code: %d.', $baseUrl, $code)
);
do_action('puc_api_error', $error, $response, $url, $this->slug);
return $error;
}
/**
* Build a fully qualified URL for an API request.
*
* @param string $url
* @param array $queryParams
* @return string
*/
protected function buildApiUrl($url, $queryParams) {
$variables = array(
'user' => $this->userName,
'repo' => $this->repositoryName,
'id' => $this->userName . '/' . $this->repositoryName,
);
foreach ($variables as $name => $value) {
$url = str_replace("/:{$name}", '/' . urlencode($value), $url);
}
$url = substr($url, 1);
$url = sprintf('%1$s://%2$s/api/v4/projects/%3$s', $this->repositoryProtocol, $this->repositoryHost, $url);
if ( !empty($this->accessToken) ) {
$queryParams['private_token'] = $this->accessToken;
}
if ( !empty($queryParams) ) {
$url = add_query_arg($queryParams, $url);
}
return $url;
}
/**
* 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') {
$response = $this->api('/:id/repository/files/' . $path, 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(
'%1$s://%2$s/api/v4/projects/%3$s/repository/archive.zip',
$this->repositoryProtocol,
$this->repositoryHost,
urlencode($this->userName . '/' . $this->repositoryName)
);
$url = add_query_arg('sha', urlencode($ref), $url);
if ( !empty($this->accessToken) ) {
$url = add_query_arg('private_token', $this->accessToken, $url);
}
return $url;
}
/**
* Get a specific tag.
*
* @param string $tagName
* @return void
*/
public function getTag($tagName) {
throw new \LogicException('The ' . __METHOD__ . ' method is not implemented and should not be used.');
}
protected function getUpdateDetectionStrategies($configBranch) {
$strategies = array();
if ( ($configBranch === 'main') || ($configBranch === 'master') ) {
$strategies[self::STRATEGY_LATEST_RELEASE] = array($this, 'getLatestRelease');
$strategies[self::STRATEGY_LATEST_TAG] = array($this, 'getLatestTag');
}
$strategies[self::STRATEGY_BRANCH] = function () use ($configBranch) {
return $this->getBranch($configBranch);
};
return $strategies;
}
public function setAuthentication($credentials) {
parent::setAuthentication($credentials);
$this->accessToken = is_string($credentials) ? $credentials : null;
}
/**
* Use release assets that link to GitLab generic packages (e.g. .zip files)
* instead of automatically generated source archives.
*
* This is included for backwards compatibility with older versions of PUC.
*
* @return void
* @deprecated Use enableReleaseAssets() instead.
* @noinspection PhpUnused -- Public API
*/
public function enableReleasePackages() {
$this->enableReleaseAssets(
/** @lang RegExp */ '/\.zip($|[?&#])/i',
Api::REQUIRE_RELEASE_ASSETS
);
}
protected function getFilterableAssetName($releaseAsset) {
if ( isset($releaseAsset->url) ) {
return $releaseAsset->url;
}
return null;
}
}
endif;

View file

@ -0,0 +1,275 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p3\Vcs;
use YahnisElsts\PluginUpdateChecker\v5p3\Plugin;
if ( !class_exists(PluginUpdateChecker::class, false) ):
class PluginUpdateChecker extends Plugin\UpdateChecker implements BaseChecker {
use VcsCheckerMethods;
/**
* PluginUpdateChecker constructor.
*
* @param Api $api
* @param string $pluginFile
* @param string $slug
* @param int $checkPeriod
* @param string $optionName
* @param string $muPluginFile
*/
public function __construct($api, $pluginFile, $slug = '', $checkPeriod = 12, $optionName = '', $muPluginFile = '') {
$this->api = $api;
parent::__construct($api->getRepositoryUrl(), $pluginFile, $slug, $checkPeriod, $optionName, $muPluginFile);
$this->api->setHttpFilterName($this->getUniqueName('request_info_options'));
$this->api->setStrategyFilterName($this->getUniqueName('vcs_update_detection_strategies'));
$this->api->setSlug($this->slug);
}
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.
if ( function_exists('set_time_limit') ) {
@set_time_limit(60);
}
$api = $this->api;
$api->setLocalDirectory($this->package->getAbsoluteDirectoryPath());
$info = new Plugin\PluginInfo();
$info->filename = $this->pluginFile;
$info->slug = $this->slug;
$this->setInfoFromHeader($this->package->getPluginHeader(), $info);
$this->setIconsFromLocalAssets($info);
$this->setBannersFromLocalAssets($info);
//Pick a branch or tag.
$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.
do_action(
'puc_api_error',
new \WP_Error(
'puc-no-update-source',
'Could not retrieve version information from the repository. '
. 'This usually means that the update checker either can\'t connect '
. 'to the repository or it\'s configured incorrectly.'
),
null, null, $this->slug
);
return null;
}
//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->package->getFileHeader($remotePlugin);
$this->setInfoFromHeader($remoteHeader, $info);
}
//Sanity check: Reject updates that don't have a version number.
//This can happen when we're using a branch, and we either fail to retrieve the main plugin
//file or the file doesn't have a "Version" header.
if ( empty($info->version) ) {
do_action(
'puc_api_error',
new \WP_Error(
'puc-no-plugin-version',
'Could not find the version number in the repository.'
),
null, null, $this->slug
);
return null;
}
//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, $this->package->getAbsoluteDirectoryPath());
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() {
return $this->package->fileExists($this->api->getLocalReadmeName());
}
/**
* Copy plugin metadata from a file header to a Plugin Info object.
*
* @param array $fileHeader
* @param Plugin\PluginInfo $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',
'Requires PHP' => 'requires_php',
);
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 Plugin\PluginInfo $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 ( !empty($readme['requires_php']) ) {
$pluginInfo->requires_php = $readme['requires_php'];
}
if ( isset($readme['upgrade_notice'], $readme['upgrade_notice'][$pluginInfo->version]) ) {
$pluginInfo->upgrade_notice = $readme['upgrade_notice'][$pluginInfo->version];
}
}
/**
* Add icons from the currently installed version to a Plugin Info object.
*
* The icons should be in a subdirectory named "assets". Supported image formats
* and file names are described here:
* @link https://developer.wordpress.org/plugins/wordpress-org/plugin-assets/#plugin-icons
*
* @param Plugin\PluginInfo $pluginInfo
*/
protected function setIconsFromLocalAssets($pluginInfo) {
$icons = $this->getLocalAssetUrls(array(
'icon.svg' => 'svg',
'icon-256x256.png' => '2x',
'icon-256x256.jpg' => '2x',
'icon-128x128.png' => '1x',
'icon-128x128.jpg' => '1x',
));
if ( !empty($icons) ) {
//The "default" key seems to be used only as last-resort fallback in WP core (5.8/5.9),
//but we'll set it anyway in case some code somewhere needs it.
reset($icons);
$firstKey = key($icons);
$icons['default'] = $icons[$firstKey];
$pluginInfo->icons = $icons;
}
}
/**
* Add banners from the currently installed version to a Plugin Info object.
*
* The banners should be in a subdirectory named "assets". Supported image formats
* and file names are described here:
* @link https://developer.wordpress.org/plugins/wordpress-org/plugin-assets/#plugin-headers
*
* @param Plugin\PluginInfo $pluginInfo
*/
protected function setBannersFromLocalAssets($pluginInfo) {
$banners = $this->getLocalAssetUrls(array(
'banner-772x250.png' => 'high',
'banner-772x250.jpg' => 'high',
'banner-1544x500.png' => 'low',
'banner-1544x500.jpg' => 'low',
));
if ( !empty($banners) ) {
$pluginInfo->banners = $banners;
}
}
/**
* @param array<string, string> $filesToKeys
* @return array<string, string>
*/
protected function getLocalAssetUrls($filesToKeys) {
$assetDirectory = $this->package->getAbsoluteDirectoryPath() . DIRECTORY_SEPARATOR . 'assets';
if ( !is_dir($assetDirectory) ) {
return array();
}
$assetBaseUrl = trailingslashit(plugins_url('', $assetDirectory . '/imaginary.file'));
$foundAssets = array();
foreach ($filesToKeys as $fileName => $key) {
$fullBannerPath = $assetDirectory . DIRECTORY_SEPARATOR . $fileName;
if ( !isset($icons[$key]) && is_file($fullBannerPath) ) {
$foundAssets[$key] = $assetBaseUrl . $fileName;
}
}
return $foundAssets;
}
}
endif;

View file

@ -0,0 +1,51 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p3\Vcs;
if ( !class_exists(Reference::class, false) ):
/**
* This class represents a VCS branch or tag. It's intended as a read only, short-lived container
* that only exists to provide a limited degree of type checking.
*
* @property string $name
* @property string|null version
* @property string $downloadUrl
* @property string $updated
*
* @property string|null $changelog
* @property int|null $downloadCount
*/
class Reference {
private $properties = array();
public function __construct($properties = array()) {
$this->properties = $properties;
}
/**
* @param string $name
* @return mixed|null
*/
public function __get($name) {
return array_key_exists($name, $this->properties) ? $this->properties[$name] : null;
}
/**
* @param string $name
* @param mixed $value
*/
public function __set($name, $value) {
$this->properties[$name] = $value;
}
/**
* @param string $name
* @return bool
*/
public function __isset($name) {
return isset($this->properties[$name]);
}
}
endif;

View file

@ -0,0 +1,83 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p3\Vcs;
if ( !trait_exists(ReleaseAssetSupport::class, false) ) :
trait ReleaseAssetSupport {
/**
* @var bool Whether to download release assets instead of the auto-generated
* source code archives.
*/
protected $releaseAssetsEnabled = false;
/**
* @var string|null Regular expression that's used to filter release assets
* by file name or URL. Optional.
*/
protected $assetFilterRegex = null;
/**
* How to handle releases that don't have any matching release assets.
*
* @var int
*/
protected $releaseAssetPreference = Api::PREFER_RELEASE_ASSETS;
/**
* Enable updating via release assets.
*
* If the latest release contains no usable assets, the update checker
* will fall back to using the automatically generated ZIP archive.
*
* @param string|null $nameRegex Optional. Use only those assets where
* the file name or URL matches this regex.
* @param int $preference Optional. How to handle releases that don't have
* any matching release assets.
*/
public function enableReleaseAssets($nameRegex = null, $preference = Api::PREFER_RELEASE_ASSETS) {
$this->releaseAssetsEnabled = true;
$this->assetFilterRegex = $nameRegex;
$this->releaseAssetPreference = $preference;
}
/**
* Disable release assets.
*
* @return void
* @noinspection PhpUnused -- Public API
*/
public function disableReleaseAssets() {
$this->releaseAssetsEnabled = false;
$this->assetFilterRegex = null;
}
/**
* Does the specified asset match the name regex?
*
* @param mixed $releaseAsset Data type and structure depend on the host/API.
* @return bool
*/
protected function matchesAssetFilter($releaseAsset) {
if ( $this->assetFilterRegex === null ) {
//The default is to accept all assets.
return true;
}
$name = $this->getFilterableAssetName($releaseAsset);
if ( !is_string($name) ) {
return false;
}
return (bool)preg_match($this->assetFilterRegex, $releaseAsset->name);
}
/**
* Get the part of asset data that will be checked against the filter regex.
*
* @param mixed $releaseAsset
* @return string|null
*/
abstract protected function getFilterableAssetName($releaseAsset);
}
endif;

View file

@ -0,0 +1,108 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p3\Vcs;
if ( !trait_exists(ReleaseFilteringFeature::class, false) ) :
trait ReleaseFilteringFeature {
/**
* @var callable|null
*/
protected $releaseFilterCallback = null;
/**
* @var int
*/
protected $releaseFilterMaxReleases = 1;
/**
* @var string One of the Api::RELEASE_FILTER_* constants.
*/
protected $releaseFilterByType = Api::RELEASE_FILTER_SKIP_PRERELEASE;
/**
* Set a custom release filter.
*
* Setting a new filter will override the old filter, if any.
*
* @param callable $callback A callback that accepts a version number and a release
* object, and returns a boolean.
* @param int $releaseTypes One of the Api::RELEASE_FILTER_* constants.
* @param int $maxReleases Optional. The maximum number of recent releases to examine
* when trying to find a release that matches the filter. 1 to 100.
* @return $this
*/
public function setReleaseFilter(
$callback,
$releaseTypes = Api::RELEASE_FILTER_SKIP_PRERELEASE,
$maxReleases = 20
) {
if ( $maxReleases > 100 ) {
throw new \InvalidArgumentException(sprintf(
'The max number of releases is too high (%d). It must be 100 or less.',
$maxReleases
));
} else if ( $maxReleases < 1 ) {
throw new \InvalidArgumentException(sprintf(
'The max number of releases is too low (%d). It must be at least 1.',
$maxReleases
));
}
$this->releaseFilterCallback = $callback;
$this->releaseFilterByType = $releaseTypes;
$this->releaseFilterMaxReleases = $maxReleases;
return $this;
}
/**
* Filter releases by their version number.
*
* @param string $regex A regular expression. The release version number must match this regex.
* @param int $releaseTypes
* @param int $maxReleasesToExamine
* @return $this
* @noinspection PhpUnused -- Public API
*/
public function setReleaseVersionFilter(
$regex,
$releaseTypes = Api::RELEASE_FILTER_SKIP_PRERELEASE,
$maxReleasesToExamine = 20
) {
return $this->setReleaseFilter(
function ($versionNumber) use ($regex) {
return (preg_match($regex, $versionNumber) === 1);
},
$releaseTypes,
$maxReleasesToExamine
);
}
/**
* @param string $versionNumber The detected release version number.
* @param object $releaseObject Varies depending on the host/API.
* @return bool
*/
protected function matchesCustomReleaseFilter($versionNumber, $releaseObject) {
if ( !is_callable($this->releaseFilterCallback) ) {
return true; //No custom filter.
}
return call_user_func($this->releaseFilterCallback, $versionNumber, $releaseObject);
}
/**
* @return bool
*/
protected function shouldSkipPreReleases() {
//Maybe this could be a bitfield in the future, if we need to support
//more release types.
return ($this->releaseFilterByType !== Api::RELEASE_FILTER_ALL);
}
/**
* @return bool
*/
protected function hasCustomReleaseFilter() {
return isset($this->releaseFilterCallback) && is_callable($this->releaseFilterCallback);
}
}
endif;

View file

@ -0,0 +1,83 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p3\Vcs;
use YahnisElsts\PluginUpdateChecker\v5p3\Theme;
use YahnisElsts\PluginUpdateChecker\v5p3\Utils;
if ( !class_exists(ThemeUpdateChecker::class, false) ):
class ThemeUpdateChecker extends Theme\UpdateChecker implements BaseChecker {
use VcsCheckerMethods;
/**
* ThemeUpdateChecker constructor.
*
* @param Api $api
* @param null $stylesheet
* @param null $customSlug
* @param int $checkPeriod
* @param string $optionName
*/
public function __construct($api, $stylesheet = null, $customSlug = null, $checkPeriod = 12, $optionName = '') {
$this->api = $api;
parent::__construct($api->getRepositoryUrl(), $stylesheet, $customSlug, $checkPeriod, $optionName);
$this->api->setHttpFilterName($this->getUniqueName('request_update_options'));
$this->api->setStrategyFilterName($this->getUniqueName('vcs_update_detection_strategies'));
$this->api->setSlug($this->slug);
}
public function requestUpdate() {
$api = $this->api;
$api->setLocalDirectory($this->package->getAbsoluteDirectoryPath());
$update = new 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);
if ( $updateSource ) {
$ref = $updateSource->name;
$update->download_url = $updateSource->downloadUrl;
} else {
do_action(
'puc_api_error',
new \WP_Error(
'puc-no-update-source',
'Could not retrieve version information from the repository. '
. 'This usually means that the update checker either can\'t connect '
. 'to the repository or it\'s configured incorrectly.'
),
null, null, $this->slug
);
$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.
$remoteHeader = $this->package->getFileHeader($api->getRemoteFile('style.css', $ref));
$update->version = Utils::findNotEmpty(array(
$remoteHeader['Version'],
Utils::get($updateSource, 'version'),
));
//The details URL defaults to the Theme URI header or the repository URL.
$update->details_url = Utils::findNotEmpty(array(
$remoteHeader['ThemeURI'],
$this->package->getHeaderValue('ThemeURI'),
$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;
}
}
endif;

View file

@ -0,0 +1,59 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p3\Vcs;
if ( !trait_exists(VcsCheckerMethods::class, false) ) :
trait VcsCheckerMethods {
/**
* @var string The branch where to look for updates. Defaults to "master".
*/
protected $branch = 'master';
/**
* @var Api Repository API client.
*/
protected $api = null;
public function setBranch($branch) {
$this->branch = $branch;
return $this;
}
/**
* Set authentication credentials.
*
* @param array|string $credentials
* @return $this
*/
public function setAuthentication($credentials) {
$this->api->setAuthentication($credentials);
return $this;
}
/**
* @return Api
*/
public function getVcsApi() {
return $this->api;
}
public function getUpdate() {
$update = parent::getUpdate();
if ( isset($update) && !empty($update->download_url) ) {
$update->download_url = $this->api->signDownloadUrl($update->download_url);
}
return $update;
}
public function onDisplayConfiguration($panel) {
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));
}
}
endif;