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 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; } /** * @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. * * @param $credentials */ public function setAuthentication($credentials) { $this->credentials = $credentials; } public function isAuthenticationEnabled() { 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']); } } /** * @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;