GitHubApi.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441
  1. <?php
  2. if ( !class_exists('Puc_v4p11_Vcs_GitHubApi', false) ):
  3. class Puc_v4p11_Vcs_GitHubApi extends Puc_v4p11_Vcs_Api {
  4. /**
  5. * @var string GitHub username.
  6. */
  7. protected $userName;
  8. /**
  9. * @var string GitHub repository name.
  10. */
  11. protected $repositoryName;
  12. /**
  13. * @var string Either a fully qualified repository URL, or just "user/repo-name".
  14. */
  15. protected $repositoryUrl;
  16. /**
  17. * @var string GitHub authentication token. Optional.
  18. */
  19. protected $accessToken;
  20. /**
  21. * @var bool Whether to download release assets instead of the auto-generated source code archives.
  22. */
  23. protected $releaseAssetsEnabled = false;
  24. /**
  25. * @var string|null Regular expression that's used to filter release assets by name. Optional.
  26. */
  27. protected $assetFilterRegex = null;
  28. /**
  29. * @var string|null The unchanging part of a release asset URL. Used to identify download attempts.
  30. */
  31. protected $assetApiBaseUrl = null;
  32. /**
  33. * @var bool
  34. */
  35. private $downloadFilterAdded = false;
  36. public function __construct($repositoryUrl, $accessToken = null) {
  37. $path = parse_url($repositoryUrl, PHP_URL_PATH);
  38. if ( preg_match('@^/?(?P<username>[^/]+?)/(?P<repository>[^/#?&]+?)/?$@', $path, $matches) ) {
  39. $this->userName = $matches['username'];
  40. $this->repositoryName = $matches['repository'];
  41. } else {
  42. throw new InvalidArgumentException('Invalid GitHub repository URL: "' . $repositoryUrl . '"');
  43. }
  44. parent::__construct($repositoryUrl, $accessToken);
  45. }
  46. /**
  47. * Get the latest release from GitHub.
  48. *
  49. * @return Puc_v4p11_Vcs_Reference|null
  50. */
  51. public function getLatestRelease() {
  52. $release = $this->api('/repos/:user/:repo/releases/latest');
  53. if ( is_wp_error($release) || !is_object($release) || !isset($release->tag_name) ) {
  54. return null;
  55. }
  56. $reference = new Puc_v4p11_Vcs_Reference(array(
  57. 'name' => $release->tag_name,
  58. 'version' => ltrim($release->tag_name, 'v'), //Remove the "v" prefix from "v1.2.3".
  59. 'downloadUrl' => $release->zipball_url,
  60. 'updated' => $release->created_at,
  61. 'apiResponse' => $release,
  62. ));
  63. if ( isset($release->assets[0]) ) {
  64. $reference->downloadCount = $release->assets[0]->download_count;
  65. }
  66. if ( $this->releaseAssetsEnabled && isset($release->assets, $release->assets[0]) ) {
  67. //Use the first release asset that matches the specified regular expression.
  68. $matchingAssets = array_filter($release->assets, array($this, 'matchesAssetFilter'));
  69. if ( !empty($matchingAssets) ) {
  70. if ( $this->isAuthenticationEnabled() ) {
  71. /**
  72. * Keep in mind that we'll need to add an "Accept" header to download this asset.
  73. *
  74. * @see setUpdateDownloadHeaders()
  75. */
  76. $reference->downloadUrl = $matchingAssets[0]->url;
  77. } else {
  78. //It seems that browser_download_url only works for public repositories.
  79. //Using an access_token doesn't help. Maybe OAuth would work?
  80. $reference->downloadUrl = $matchingAssets[0]->browser_download_url;
  81. }
  82. $reference->downloadCount = $matchingAssets[0]->download_count;
  83. }
  84. }
  85. if ( !empty($release->body) ) {
  86. /** @noinspection PhpUndefinedClassInspection */
  87. $reference->changelog = Parsedown::instance()->text($release->body);
  88. }
  89. return $reference;
  90. }
  91. /**
  92. * Get the tag that looks like the highest version number.
  93. *
  94. * @return Puc_v4p11_Vcs_Reference|null
  95. */
  96. public function getLatestTag() {
  97. $tags = $this->api('/repos/:user/:repo/tags');
  98. if ( is_wp_error($tags) || !is_array($tags) ) {
  99. return null;
  100. }
  101. $versionTags = $this->sortTagsByVersion($tags);
  102. if ( empty($versionTags) ) {
  103. return null;
  104. }
  105. $tag = $versionTags[0];
  106. return new Puc_v4p11_Vcs_Reference(array(
  107. 'name' => $tag->name,
  108. 'version' => ltrim($tag->name, 'v'),
  109. 'downloadUrl' => $tag->zipball_url,
  110. 'apiResponse' => $tag,
  111. ));
  112. }
  113. /**
  114. * Get a branch by name.
  115. *
  116. * @param string $branchName
  117. * @return null|Puc_v4p11_Vcs_Reference
  118. */
  119. public function getBranch($branchName) {
  120. $branch = $this->api('/repos/:user/:repo/branches/' . $branchName);
  121. if ( is_wp_error($branch) || empty($branch) ) {
  122. return null;
  123. }
  124. $reference = new Puc_v4p11_Vcs_Reference(array(
  125. 'name' => $branch->name,
  126. 'downloadUrl' => $this->buildArchiveDownloadUrl($branch->name),
  127. 'apiResponse' => $branch,
  128. ));
  129. if ( isset($branch->commit, $branch->commit->commit, $branch->commit->commit->author->date) ) {
  130. $reference->updated = $branch->commit->commit->author->date;
  131. }
  132. return $reference;
  133. }
  134. /**
  135. * Get the latest commit that changed the specified file.
  136. *
  137. * @param string $filename
  138. * @param string $ref Reference name (e.g. branch or tag).
  139. * @return StdClass|null
  140. */
  141. public function getLatestCommit($filename, $ref = 'master') {
  142. $commits = $this->api(
  143. '/repos/:user/:repo/commits',
  144. array(
  145. 'path' => $filename,
  146. 'sha' => $ref,
  147. )
  148. );
  149. if ( !is_wp_error($commits) && isset($commits[0]) ) {
  150. return $commits[0];
  151. }
  152. return null;
  153. }
  154. /**
  155. * Get the timestamp of the latest commit that changed the specified branch or tag.
  156. *
  157. * @param string $ref Reference name (e.g. branch or tag).
  158. * @return string|null
  159. */
  160. public function getLatestCommitTime($ref) {
  161. $commits = $this->api('/repos/:user/:repo/commits', array('sha' => $ref));
  162. if ( !is_wp_error($commits) && isset($commits[0]) ) {
  163. return $commits[0]->commit->author->date;
  164. }
  165. return null;
  166. }
  167. /**
  168. * Perform a GitHub API request.
  169. *
  170. * @param string $url
  171. * @param array $queryParams
  172. * @return mixed|WP_Error
  173. */
  174. protected function api($url, $queryParams = array()) {
  175. $baseUrl = $url;
  176. $url = $this->buildApiUrl($url, $queryParams);
  177. $options = array('timeout' => 10);
  178. if ( $this->isAuthenticationEnabled() ) {
  179. $options['headers'] = array('Authorization' => $this->getAuthorizationHeader());
  180. }
  181. if ( !empty($this->httpFilterName) ) {
  182. $options = apply_filters($this->httpFilterName, $options);
  183. }
  184. $response = wp_remote_get($url, $options);
  185. if ( is_wp_error($response) ) {
  186. do_action('puc_api_error', $response, null, $url, $this->slug);
  187. return $response;
  188. }
  189. $code = wp_remote_retrieve_response_code($response);
  190. $body = wp_remote_retrieve_body($response);
  191. if ( $code === 200 ) {
  192. $document = json_decode($body);
  193. return $document;
  194. }
  195. $error = new WP_Error(
  196. 'puc-github-http-error',
  197. sprintf('GitHub API error. Base URL: "%s", HTTP status code: %d.', $baseUrl, $code)
  198. );
  199. do_action('puc_api_error', $error, $response, $url, $this->slug);
  200. return $error;
  201. }
  202. /**
  203. * Build a fully qualified URL for an API request.
  204. *
  205. * @param string $url
  206. * @param array $queryParams
  207. * @return string
  208. */
  209. protected function buildApiUrl($url, $queryParams) {
  210. $variables = array(
  211. 'user' => $this->userName,
  212. 'repo' => $this->repositoryName,
  213. );
  214. foreach ($variables as $name => $value) {
  215. $url = str_replace('/:' . $name, '/' . urlencode($value), $url);
  216. }
  217. $url = 'https://api.github.com' . $url;
  218. if ( !empty($queryParams) ) {
  219. $url = add_query_arg($queryParams, $url);
  220. }
  221. return $url;
  222. }
  223. /**
  224. * Get the contents of a file from a specific branch or tag.
  225. *
  226. * @param string $path File name.
  227. * @param string $ref
  228. * @return null|string Either the contents of the file, or null if the file doesn't exist or there's an error.
  229. */
  230. public function getRemoteFile($path, $ref = 'master') {
  231. $apiUrl = '/repos/:user/:repo/contents/' . $path;
  232. $response = $this->api($apiUrl, array('ref' => $ref));
  233. if ( is_wp_error($response) || !isset($response->content) || ($response->encoding !== 'base64') ) {
  234. return null;
  235. }
  236. return base64_decode($response->content);
  237. }
  238. /**
  239. * Generate a URL to download a ZIP archive of the specified branch/tag/etc.
  240. *
  241. * @param string $ref
  242. * @return string
  243. */
  244. public function buildArchiveDownloadUrl($ref = 'master') {
  245. $url = sprintf(
  246. 'https://api.github.com/repos/%1$s/%2$s/zipball/%3$s',
  247. urlencode($this->userName),
  248. urlencode($this->repositoryName),
  249. urlencode($ref)
  250. );
  251. return $url;
  252. }
  253. /**
  254. * Get a specific tag.
  255. *
  256. * @param string $tagName
  257. * @return void
  258. */
  259. public function getTag($tagName) {
  260. //The current GitHub update checker doesn't use getTag, so I didn't bother to implement it.
  261. throw new LogicException('The ' . __METHOD__ . ' method is not implemented and should not be used.');
  262. }
  263. public function setAuthentication($credentials) {
  264. parent::setAuthentication($credentials);
  265. $this->accessToken = is_string($credentials) ? $credentials : null;
  266. //Optimization: Instead of filtering all HTTP requests, let's do it only when
  267. //WordPress is about to download an update.
  268. add_filter('upgrader_pre_download', array($this, 'addHttpRequestFilter'), 10, 1); //WP 3.7+
  269. }
  270. /**
  271. * Figure out which reference (i.e tag or branch) contains the latest version.
  272. *
  273. * @param string $configBranch Start looking in this branch.
  274. * @return null|Puc_v4p11_Vcs_Reference
  275. */
  276. public function chooseReference($configBranch) {
  277. $updateSource = null;
  278. if ( $configBranch === 'master' ) {
  279. //Use the latest release.
  280. $updateSource = $this->getLatestRelease();
  281. if ( $updateSource === null ) {
  282. //Failing that, use the tag with the highest version number.
  283. $updateSource = $this->getLatestTag();
  284. }
  285. }
  286. //Alternatively, just use the branch itself.
  287. if ( empty($updateSource) ) {
  288. $updateSource = $this->getBranch($configBranch);
  289. }
  290. return $updateSource;
  291. }
  292. /**
  293. * Enable updating via release assets.
  294. *
  295. * If the latest release contains no usable assets, the update checker
  296. * will fall back to using the automatically generated ZIP archive.
  297. *
  298. * Private repositories will only work with WordPress 3.7 or later.
  299. *
  300. * @param string|null $fileNameRegex Optional. Use only those assets where the file name matches this regex.
  301. */
  302. public function enableReleaseAssets($fileNameRegex = null) {
  303. $this->releaseAssetsEnabled = true;
  304. $this->assetFilterRegex = $fileNameRegex;
  305. $this->assetApiBaseUrl = sprintf(
  306. '//api.github.com/repos/%1$s/%2$s/releases/assets/',
  307. $this->userName,
  308. $this->repositoryName
  309. );
  310. }
  311. /**
  312. * Does this asset match the file name regex?
  313. *
  314. * @param stdClass $releaseAsset
  315. * @return bool
  316. */
  317. protected function matchesAssetFilter($releaseAsset) {
  318. if ( $this->assetFilterRegex === null ) {
  319. //The default is to accept all assets.
  320. return true;
  321. }
  322. return isset($releaseAsset->name) && preg_match($this->assetFilterRegex, $releaseAsset->name);
  323. }
  324. /**
  325. * @internal
  326. * @param bool $result
  327. * @return bool
  328. */
  329. public function addHttpRequestFilter($result) {
  330. if ( !$this->downloadFilterAdded && $this->isAuthenticationEnabled() ) {
  331. add_filter('http_request_args', array($this, 'setUpdateDownloadHeaders'), 10, 2);
  332. add_action('requests-requests.before_redirect', array($this, 'removeAuthHeaderFromRedirects'), 10, 4);
  333. $this->downloadFilterAdded = true;
  334. }
  335. return $result;
  336. }
  337. /**
  338. * Set the HTTP headers that are necessary to download updates from private repositories.
  339. *
  340. * See GitHub docs:
  341. * @link https://developer.github.com/v3/repos/releases/#get-a-single-release-asset
  342. * @link https://developer.github.com/v3/auth/#basic-authentication
  343. *
  344. * @internal
  345. * @param array $requestArgs
  346. * @param string $url
  347. * @return array
  348. */
  349. public function setUpdateDownloadHeaders($requestArgs, $url = '') {
  350. //Is WordPress trying to download one of our release assets?
  351. if ( $this->releaseAssetsEnabled && (strpos($url, $this->assetApiBaseUrl) !== false) ) {
  352. $requestArgs['headers']['Accept'] = 'application/octet-stream';
  353. }
  354. //Use Basic authentication, but only if the download is from our repository.
  355. $repoApiBaseUrl = $this->buildApiUrl('/repos/:user/:repo/', array());
  356. if ( $this->isAuthenticationEnabled() && (strpos($url, $repoApiBaseUrl)) === 0 ) {
  357. $requestArgs['headers']['Authorization'] = $this->getAuthorizationHeader();
  358. }
  359. return $requestArgs;
  360. }
  361. /**
  362. * When following a redirect, the Requests library will automatically forward
  363. * the authorization header to other hosts. We don't want that because it breaks
  364. * AWS downloads and can leak authorization information.
  365. *
  366. * @internal
  367. * @param string $location
  368. * @param array $headers
  369. */
  370. public function removeAuthHeaderFromRedirects(&$location, &$headers) {
  371. $repoApiBaseUrl = $this->buildApiUrl('/repos/:user/:repo/', array());
  372. if ( strpos($location, $repoApiBaseUrl) === 0 ) {
  373. return; //This request is going to GitHub, so it's fine.
  374. }
  375. //Remove the header.
  376. if ( isset($headers['Authorization']) ) {
  377. unset($headers['Authorization']);
  378. }
  379. }
  380. /**
  381. * Generate the value of the "Authorization" header.
  382. *
  383. * @return string
  384. */
  385. protected function getAuthorizationHeader() {
  386. return 'Basic ' . base64_encode($this->userName . ':' . $this->accessToken);
  387. }
  388. }
  389. endif;