GitHubApi.php 13 KB

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