diff --git a/Puc/v4/BitBucket/Api.php b/Puc/v4/BitBucket/Api.php index 8978571..89c86ba 100644 --- a/Puc/v4/BitBucket/Api.php +++ b/Puc/v4/BitBucket/Api.php @@ -2,16 +2,50 @@ if ( !class_exists('Puc_v4_BitBucket_Api', false) ): class Puc_v4_BitBucket_Api { - public function __construct($repositoryUrl, $credentials = array()) { + /** + * @var Puc_v4_OAuthSignature + */ + private $oauth = null; + /** + * @var string + */ + private $username; + + /** + * @var string + */ + private $repository; + + public function __construct($repositoryUrl, $credentials = array()) { + $path = @parse_url($repositoryUrl, PHP_URL_PATH); + if ( preg_match('@^/?(?P[^/]+?)/(?P[^/#?&]+?)/?$@', $path, $matches) ) { + $this->username = $matches['username']; + $this->repository = $matches['repository']; + } else { + throw new InvalidArgumentException('Invalid BitBucket repository URL: "' . $repositoryUrl . '"'); + } + + if ( !empty($credentials) && !empty($credentials['consumer_key']) ) { + $this->oauth = new Puc_v4_OAuthSignature( + $credentials['consumer_key'], + $credentials['consumer_secret'] + ); + } } /** * @param string $ref * @return array */ - public function getRemoteReadme($ref) { - return array(); + public function getRemoteReadme($ref = 'master') { + $fileContents = $this->getRemoteFile('readme.txt', $ref); + if ( empty($fileContents) ) { + return array(); + } + + $parser = new PucReadmeParser(); + return $parser->parse_readme_contents($fileContents); } /** @@ -21,7 +55,11 @@ if ( !class_exists('Puc_v4_BitBucket_Api', false) ): * @return stdClass|null */ public function getTag($tagName) { - return null; + $tag = $this->api('/refs/tags/' . $tagName); + if ( is_wp_error($tag) || empty($tag) ) { + return null; + } + return $tag; } /** @@ -30,9 +68,63 @@ if ( !class_exists('Puc_v4_BitBucket_Api', false) ): * @return stdClass|null */ public function getLatestTag() { + $tags = $this->api('/refs/tags'); + if ( !isset($tags, $tags->values) || !is_array($tags->values) ) { + return null; + } + + //Keep only those tags that look like version numbers. + $versionTags = array_filter($tags->values, array($this, 'isVersionTag')); + //Sort them in descending order. + usort($versionTags, array($this, 'compareTagNames')); + + //Return the first result. + if ( !empty($versionTags) ) { + return $versionTags[0]; + } return null; } + protected function isVersionTag($tag) { + return isset($tag->name) && $this->looksLikeVersion($tag->name); + } + + /** + * 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($name) ) { + 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); + } + + /** + * Compare two BitBucket tags as if they were version number. + * + * @param string $tag1 + * @param string $tag2 + * @return int + */ + protected function compareTagNames($tag1, $tag2) { + if ( !isset($tag1->name) ) { + return 1; + } + if ( !isset($tag2->name) ) { + return -1; + } + return -version_compare(ltrim($tag1->name, 'v'), ltrim($tag2->name, 'v')); + } + /** * Get the contents of a file from a specific branch or tag. * @@ -41,7 +133,11 @@ if ( !class_exists('Puc_v4_BitBucket_Api', false) ): * @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') { - return null; + $response = $this->api('src/' . $ref . '/' . ltrim($path), '1.0'); + if ( is_wp_error($response) || !isset($response, $response->data) ) { + return null; + } + return $response->data; } /** @@ -51,6 +147,10 @@ if ( !class_exists('Puc_v4_BitBucket_Api', false) ): * @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; } @@ -84,6 +184,48 @@ if ( !class_exists('Puc_v4_BitBucket_Api', false) ): } 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') { + //printf('Requesting %s
' . "\n", $url); + + $url = implode('/', array( + 'https://api.bitbucket.org', + $version, + 'repositories', + $this->username, + $this->repository, + ltrim($url, '/') + )); + + if ( $this->oauth ) { + $url = $this->oauth->sign($url,'GET'); + } + + $response = wp_remote_get($url, array('timeout' => 10)); + //var_dump($response); + if ( is_wp_error($response) ) { + return $response; + } + + $code = wp_remote_retrieve_response_code($response); + $body = wp_remote_retrieve_body($response); + if ( $code === 200 ) { + $document = json_decode($body); + return $document; + } + + return new WP_Error( + 'puc-bitbucket-http-error', + 'BitBucket API error. HTTP status: ' . $code + ); + } } endif; \ No newline at end of file diff --git a/Puc/v4/BitBucket/PluginUpdateChecker.php b/Puc/v4/BitBucket/PluginUpdateChecker.php index 112f63e..927bf0a 100644 --- a/Puc/v4/BitBucket/PluginUpdateChecker.php +++ b/Puc/v4/BitBucket/PluginUpdateChecker.php @@ -5,24 +5,17 @@ if ( !class_exists('Puc_v4_BitBucket_PluginUpdateChecker') ): /** * @var string */ - protected $repositoryUrl; - - /** - * @var string - */ - protected $branch; + protected $branch = 'master'; /** * @var Puc_v4_BitBucket_Api */ protected $api; + protected $credentials = array(); + public function requestInfo($queryArgs = array()) { - //TODO: BitBucket support - $api = $this->api = new Puc_v4_BitBucket_Api( - $this->repositoryUrl, - array() - ); + $api = $this->api = new Puc_v4_BitBucket_Api($this->metadataUrl, $this->credentials); $info = new Puc_v4_Plugin_Info(); $info->filename = $this->pluginFile; @@ -42,7 +35,6 @@ if ( !class_exists('Puc_v4_BitBucket_PluginUpdateChecker') ): $ref = $tag->name; $info->version = ltrim($tag->name, 'v'); $info->last_updated = $tag->target->date; - //TODO: Download url $foundVersion = true; } } @@ -54,7 +46,6 @@ if ( !class_exists('Puc_v4_BitBucket_PluginUpdateChecker') ): $ref = $tag->name; $info->version = ltrim($tag->name, 'v'); $info->last_updated = $tag->target->date; - //TODO: Download url $foundVersion = true; } } @@ -62,9 +53,10 @@ if ( !class_exists('Puc_v4_BitBucket_PluginUpdateChecker') ): //If all else fails, use the specified branch itself. if ( !$foundVersion ) { $ref = $this->branch; - //TODO: Download url for this branch. } + $info->download_url = trailingslashit($this->metadataUrl) . 'get/' . $ref . '.zip'; + //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); @@ -76,7 +68,7 @@ if ( !class_exists('Puc_v4_BitBucket_PluginUpdateChecker') ): //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() ) { + if ( $this->readmeTxtExistsLocally() || !empty($remoteReadme) ) { $this->setInfoFromRemoteReadme($ref, $info); } @@ -100,6 +92,16 @@ if ( !class_exists('Puc_v4_BitBucket_PluginUpdateChecker') ): return $info; } + public function setAuthentication($credentials) { + $this->credentials = array_merge( + array( + 'consumer_key' => '', + 'consumer_secret' => '', + ), + $credentials + ); + } + /** * Check if the currently installed version has a readme.txt file. * @@ -170,6 +172,26 @@ if ( !class_exists('Puc_v4_BitBucket_PluginUpdateChecker') ): $pluginInfo->upgrade_notice = $readme['upgrade_notice'][$pluginInfo->version]; } } + + public function getUpdate() { + $update = parent::getUpdate(); + + //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 ( isset($update, $update->download_url) && !empty($update->download_url) && !empty($this->credentials) ) { + if ( !empty($this->credentials['consumer_key']) ) { + $oauth = new Puc_v4_OAuthSignature( + $this->credentials['consumer_key'], + $this->credentials['consumer_secret'] + ); + $update->download_url = $oauth->sign($update->download_url); + } + } + + return $update; + } + } endif; \ No newline at end of file diff --git a/Puc/v4/Factory.php b/Puc/v4/Factory.php index 52be8ff..3823829 100644 --- a/Puc/v4/Factory.php +++ b/Puc/v4/Factory.php @@ -31,7 +31,7 @@ if ( !class_exists('Puc_v4_Factory', false) ): * @param int $checkPeriod How often to check for updates (in hours). * @param string $optionName Where to store book-keeping info about update checks. * @param string $muPluginFile The plugin filename relative to the mu-plugins directory. - * @return Puc_v4_UpdateChecker + * @return Puc_v4_Plugin_UpdateChecker|Puc_v4_Theme_UpdateChecker */ public static function buildUpdateChecker($metadataUrl, $fullPath, $slug = '', $checkPeriod = 12, $optionName = '', $muPluginFile = '') { $fullPath = wp_normalize_path($fullPath); diff --git a/Puc/v4/OAuthSignature.php b/Puc/v4/OAuthSignature.php new file mode 100644 index 0000000..f9242c4 --- /dev/null +++ b/Puc/v4/OAuthSignature.php @@ -0,0 +1,87 @@ +consumerKey = $consumerKey; + $this->consumerSecret = $consumerSecret; + } + + /** + * Sign a URL using OAuth 1.0. + * + * @param string $url The URL to be signed. It may contain query parameters. + * @param string $method HTTP method such as "GET", "POST" and so on. + * @return string The signed URL. + */ + public function sign($url, $method = 'GET') { + $parameters = array(); + + //Parse query parameters. + $query = @parse_url($url, PHP_URL_QUERY); + if ( !empty($query) ) { + parse_str($query, $parsedParams); + if ( is_array($parameters) ) { + $parameters = $parsedParams; + } + //Remove the query string from the URL. We'll replace it later. + $url = substr($url, 0, strpos($url, '?')); + } + + $parameters = array_merge( + $parameters, + array( + 'oauth_consumer_key' => $this->consumerKey, + 'oauth_nonce' => $this->nonce(), + 'oauth_signature_method' => 'HMAC-SHA1', + 'oauth_timestamp' => time(), + 'oauth_version' => '1.0', + ) + ); + + //Parameters must be sorted alphabetically before signing. + ksort($parameters); + + //The most complicated part of the request - generating the signature. + //The string to sign contains the HTTP method, the URL path, and all of + //our query parameters. Everything is URL encoded. Then we concatenate + //them with ampersands into a single string to hash. + $encodedVerb = urlencode($method); + $encodedUrl = urlencode($url); + $encodedParams = urlencode(http_build_query($parameters, '', '&')); + + $stringToSign = $encodedVerb . '&' . $encodedUrl . '&' . $encodedParams; + + //Since we only have one OAuth token (the consumer secret) we only have + //to use it as our HMAC key. However, we still have to append an & to it + //as if we were using it with additional tokens. + $secret = urlencode($this->consumerSecret) . '&'; + + //The signature is a hash of the consumer key and the base string. Note + //that we have to get the raw output from hash_hmac and base64 encode + //the binary data result. + $parameters['oauth_signature'] = base64_encode(hash_hmac('sha1', $stringToSign, $secret, true)); + + return ($url . '?' . http_build_query($parameters)); + } + + /** + * Generate a random nonce. + * + * @return string + */ + private function nonce() { + $mt = microtime(); + $rand = mt_rand(); + return md5($mt . '_' . $rand); + } + } + +endif; \ No newline at end of file diff --git a/vendor/readme-parser.php b/vendor/readme-parser.php index a40b4b3..334bca7 100644 --- a/vendor/readme-parser.php +++ b/vendor/readme-parser.php @@ -1,11 +1,13 @@