PageRenderTime 35ms CodeModel.GetById 9ms RepoModel.GetById 0ms app.codeStats 0ms

/vendor/plugin-update-checker/github-checker.php

https://gitlab.com/axminenko/rocks-tools
PHP | 449 lines | 289 code | 54 blank | 106 comment | 58 complexity | 1b2080a8fa7aef05231d4e232f07027c MD5 | raw file
  1. <?php
  2. if ( !class_exists('PucGitHubChecker_2_1', false) ):
  3. class PucGitHubChecker_2_1 extends PluginUpdateChecker_2_1 {
  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 The branch to use as the latest version. Defaults to "master".
  18. */
  19. protected $branch;
  20. /**
  21. * @var string GitHub authentication token. Optional.
  22. */
  23. protected $accessToken;
  24. public function __construct(
  25. $repositoryUrl,
  26. $pluginFile,
  27. $branch = 'master',
  28. $checkPeriod = 12,
  29. $optionName = '',
  30. $muPluginFile = ''
  31. ) {
  32. $this->repositoryUrl = $repositoryUrl;
  33. $this->branch = empty($branch) ? 'master' : $branch;
  34. $path = @parse_url($repositoryUrl, PHP_URL_PATH);
  35. if ( preg_match('@^/?(?P<username>[^/]+?)/(?P<repository>[^/#?&]+?)/?$@', $path, $matches) ) {
  36. $this->userName = $matches['username'];
  37. $this->repositoryName = $matches['repository'];
  38. } else {
  39. throw new InvalidArgumentException('Invalid GitHub repository URL: "' . $repositoryUrl . '"');
  40. }
  41. parent::__construct($repositoryUrl, $pluginFile, '', $checkPeriod, $optionName, $muPluginFile);
  42. }
  43. /**
  44. * Retrieve details about the latest plugin version from GitHub.
  45. *
  46. * @param array $unusedQueryArgs Unused.
  47. * @return PluginInfo
  48. */
  49. public function requestInfo($unusedQueryArgs = array()) {
  50. $info = new PluginInfo_2_1();
  51. $info->filename = $this->pluginFile;
  52. $info->slug = $this->slug;
  53. $info->sections = array();
  54. $this->setInfoFromHeader($this->getPluginHeader(), $info);
  55. //Figure out which reference (tag or branch) we'll use to get the latest version of the plugin.
  56. $ref = $this->branch;
  57. if ( $this->branch === 'master' ) {
  58. //Use the latest release.
  59. $release = $this->getLatestRelease();
  60. if ( $release !== null ) {
  61. $ref = $release->tag_name;
  62. $info->version = ltrim($release->tag_name, 'v'); //Remove the "v" prefix from "v1.2.3".
  63. $info->last_updated = $release->created_at;
  64. $info->download_url = $release->zipball_url;
  65. if ( !empty($release->body) ) {
  66. $info->sections['changelog'] = $this->parseMarkdown($release->body);
  67. }
  68. if ( isset($release->assets[0]) ) {
  69. $info->downloaded = $release->assets[0]->download_count;
  70. }
  71. } else {
  72. //Failing that, use the tag with the highest version number.
  73. $tag = $this->getLatestTag();
  74. if ( $tag !== null ) {
  75. $ref = $tag->name;
  76. $info->version = $tag->name;
  77. $info->download_url = $tag->zipball_url;
  78. }
  79. }
  80. }
  81. if ( empty($info->download_url) ) {
  82. $info->download_url = $this->buildArchiveDownloadUrl($ref);
  83. }
  84. //Get headers from the main plugin file in this branch/tag. Its "Version" header and other metadata
  85. //are what the WordPress install will actually see after upgrading, so they take precedence over releases/tags.
  86. $mainPluginFile = basename($this->pluginFile);
  87. $remotePlugin = $this->getRemoteFile($mainPluginFile, $ref);
  88. if ( !empty($remotePlugin) ) {
  89. $remoteHeader = $this->getFileHeader($remotePlugin);
  90. $this->setInfoFromHeader($remoteHeader, $info);
  91. }
  92. //Try parsing readme.txt. If it's formatted according to WordPress.org standards, it will contain
  93. //a lot of useful information like the required/tested WP version, changelog, and so on.
  94. if ( $this->readmeTxtExistsLocally() ) {
  95. $readmeTxt = $this->getRemoteFile('readme.txt', $ref);
  96. if ( !empty($readmeTxt) ) {
  97. $readme = $this->parseReadme($readmeTxt);
  98. if ( isset($readme['sections']) ) {
  99. $info->sections = array_merge($info->sections, $readme['sections']);
  100. }
  101. if ( !empty($readme['tested_up_to']) ) {
  102. $info->tested = $readme['tested_up_to'];
  103. }
  104. if ( !empty($readme['requires_at_least']) ) {
  105. $info->requires = $readme['requires_at_least'];
  106. }
  107. if ( isset($readme['upgrade_notice'], $readme['upgrade_notice'][$info->version]) ) {
  108. $info->upgrade_notice = $readme['upgrade_notice'][$info->version];
  109. }
  110. }
  111. }
  112. //The changelog might be in a separate file.
  113. if ( empty($info->sections['changelog']) ) {
  114. $info->sections['changelog'] = $this->getRemoteChangelog($ref);
  115. if ( empty($info->sections['changelog']) ) {
  116. $info->sections['changelog'] = 'There is no changelog available.';
  117. }
  118. }
  119. if ( empty($info->last_updated) ) {
  120. //Fetch the latest commit that changed the main plugin file and use it as the "last_updated" date.
  121. //It's reasonable to assume that every update will change the version number in that file.
  122. $latestCommit = $this->getLatestCommit($mainPluginFile, $ref);
  123. if ( $latestCommit !== null ) {
  124. $info->last_updated = $latestCommit->commit->author->date;
  125. }
  126. }
  127. $info = apply_filters('puc_request_info_result-' . $this->slug, $info, null);
  128. return $info;
  129. }
  130. /**
  131. * Get the latest release from GitHub.
  132. *
  133. * @return StdClass|null
  134. */
  135. protected function getLatestRelease() {
  136. $releases = $this->api('/repos/:user/:repo/releases');
  137. if ( is_wp_error($releases) || !is_array($releases) || !isset($releases[0]) ) {
  138. return null;
  139. }
  140. $latestRelease = $releases[0];
  141. return $latestRelease;
  142. }
  143. /**
  144. * Get the tag that looks like the highest version number.
  145. *
  146. * @return StdClass|null
  147. */
  148. protected function getLatestTag() {
  149. $tags = $this->api('/repos/:user/:repo/tags');
  150. if ( is_wp_error($tags) || empty($tags) || !is_array($tags) ) {
  151. return null;
  152. }
  153. usort($tags, array($this, 'compareTagNames')); //Sort from highest to lowest.
  154. return $tags[0];
  155. }
  156. /**
  157. * Compare two GitHub tags as if they were version number.
  158. *
  159. * @param string $tag1
  160. * @param string $tag2
  161. * @return int
  162. */
  163. protected function compareTagNames($tag1, $tag2) {
  164. if ( !isset($tag1->name) ) {
  165. return 1;
  166. }
  167. if ( !isset($tag2->name) ) {
  168. return -1;
  169. }
  170. return -version_compare($tag1->name, $tag2->name);
  171. }
  172. /**
  173. * Get the latest commit that changed the specified file.
  174. *
  175. * @param string $filename
  176. * @param string $ref Reference name (e.g. branch or tag).
  177. * @return StdClass|null
  178. */
  179. protected function getLatestCommit($filename, $ref = 'master') {
  180. $commits = $this->api(
  181. '/repos/:user/:repo/commits',
  182. array(
  183. 'path' => $filename,
  184. 'sha' => $ref,
  185. )
  186. );
  187. if ( !is_wp_error($commits) && is_array($commits) && isset($commits[0]) ) {
  188. return $commits[0];
  189. }
  190. return null;
  191. }
  192. protected function getRemoteChangelog($ref = '') {
  193. $filename = $this->getChangelogFilename();
  194. if ( empty($filename) ) {
  195. return null;
  196. }
  197. $changelog = $this->getRemoteFile($filename, $ref);
  198. if ( $changelog === null ) {
  199. return null;
  200. }
  201. return $this->parseMarkdown($changelog);
  202. }
  203. protected function getChangelogFilename() {
  204. $pluginDirectory = dirname($this->pluginAbsolutePath);
  205. if ( empty($this->pluginAbsolutePath) || !is_dir($pluginDirectory) || ($pluginDirectory === '.') ) {
  206. return null;
  207. }
  208. $possibleNames = array('CHANGES.md', 'CHANGELOG.md', 'changes.md', 'changelog.md');
  209. $files = scandir($pluginDirectory);
  210. $foundNames = array_intersect($possibleNames, $files);
  211. if ( !empty($foundNames) ) {
  212. return reset($foundNames);
  213. }
  214. return null;
  215. }
  216. /**
  217. * Convert Markdown to HTML.
  218. *
  219. * @param string $markdown
  220. * @return string
  221. */
  222. protected function parseMarkdown($markdown) {
  223. if ( !class_exists('Parsedown', false) ) {
  224. require_once(dirname(__FILE__) . '/vendor/parsedown.php');
  225. }
  226. $instance = Parsedown::instance();
  227. return $instance->text($markdown);
  228. }
  229. /**
  230. * Perform a GitHub API request.
  231. *
  232. * @param string $url
  233. * @param array $queryParams
  234. * @return mixed|WP_Error
  235. */
  236. protected function api($url, $queryParams = array()) {
  237. $variables = array(
  238. 'user' => $this->userName,
  239. 'repo' => $this->repositoryName,
  240. );
  241. foreach ($variables as $name => $value) {
  242. $url = str_replace('/:' . $name, '/' . urlencode($value), $url);
  243. }
  244. $url = 'https://api.github.com' . $url;
  245. if ( !empty($this->accessToken) ) {
  246. $queryParams['access_token'] = $this->accessToken;
  247. }
  248. if ( !empty($queryParams) ) {
  249. $url = add_query_arg($queryParams, $url);
  250. }
  251. $response = wp_remote_get($url, array('timeout' => 10));
  252. if ( is_wp_error($response) ) {
  253. return $response;
  254. }
  255. $code = wp_remote_retrieve_response_code($response);
  256. $body = wp_remote_retrieve_body($response);
  257. if ( $code === 200 ) {
  258. $document = json_decode($body);
  259. return $document;
  260. }
  261. return new WP_Error(
  262. 'puc-github-http-error',
  263. 'GitHub API error. HTTP status: ' . $code
  264. );
  265. }
  266. /**
  267. * Set the access token that will be used to make authenticated GitHub API requests.
  268. *
  269. * @param string $accessToken
  270. */
  271. public function setAccessToken($accessToken) {
  272. $this->accessToken = $accessToken;
  273. }
  274. /**
  275. * Get the contents of a file from a specific branch or tag.
  276. *
  277. * @param string $path File name.
  278. * @param string $ref
  279. * @return null|string Either the contents of the file, or null if the file doesn't exist or there's an error.
  280. */
  281. protected function getRemoteFile($path, $ref = 'master') {
  282. $apiUrl = '/repos/:user/:repo/contents/' . $path;
  283. $response = $this->api($apiUrl, array('ref' => $ref));
  284. if ( is_wp_error($response) || !isset($response->content) || ($response->encoding !== 'base64') ) {
  285. return null;
  286. }
  287. return base64_decode($response->content);
  288. }
  289. /**
  290. * Parse plugin metadata from the header comment.
  291. * This is basically a simplified version of the get_file_data() function from /wp-includes/functions.php.
  292. *
  293. * @param $content
  294. * @return array
  295. */
  296. protected function getFileHeader($content) {
  297. $headers = array(
  298. 'Name' => 'Plugin Name',
  299. 'PluginURI' => 'Plugin URI',
  300. 'Version' => 'Version',
  301. 'Description' => 'Description',
  302. 'Author' => 'Author',
  303. 'AuthorURI' => 'Author URI',
  304. 'TextDomain' => 'Text Domain',
  305. 'DomainPath' => 'Domain Path',
  306. 'Network' => 'Network',
  307. //The newest WordPress version that this plugin requires or has been tested with.
  308. //We support several different formats for compatibility with other libraries.
  309. 'Tested WP' => 'Tested WP',
  310. 'Requires WP' => 'Requires WP',
  311. 'Tested up to' => 'Tested up to',
  312. 'Requires at least' => 'Requires at least',
  313. );
  314. $content = str_replace("\r", "\n", $content); //Normalize line endings.
  315. $results = array();
  316. foreach ($headers as $field => $name) {
  317. $success = preg_match('/^[ \t\/*#@]*' . preg_quote($name, '/') . ':(.*)$/mi', $content, $matches);
  318. if ( ($success === 1) && $matches[1] ) {
  319. $results[$field] = _cleanup_header_comment($matches[1]);
  320. } else {
  321. $results[$field] = '';
  322. }
  323. }
  324. return $results;
  325. }
  326. /**
  327. * Copy plugin metadata from a file header to a PluginInfo object.
  328. *
  329. * @param array $fileHeader
  330. * @param PluginInfo_2_1 $pluginInfo
  331. */
  332. protected function setInfoFromHeader($fileHeader, $pluginInfo) {
  333. $headerToPropertyMap = array(
  334. 'Version' => 'version',
  335. 'Name' => 'name',
  336. 'PluginURI' => 'homepage',
  337. 'Author' => 'author',
  338. 'AuthorName' => 'author',
  339. 'AuthorURI' => 'author_homepage',
  340. 'Requires WP' => 'requires',
  341. 'Tested WP' => 'tested',
  342. 'Requires at least' => 'requires',
  343. 'Tested up to' => 'tested',
  344. );
  345. foreach ($headerToPropertyMap as $headerName => $property) {
  346. if ( isset($fileHeader[$headerName]) && !empty($fileHeader[$headerName]) ) {
  347. $pluginInfo->$property = $fileHeader[$headerName];
  348. }
  349. }
  350. if ( !isset($pluginInfo->sections) ) {
  351. $pluginInfo->sections = array();
  352. }
  353. if ( !empty($fileHeader['Description']) ) {
  354. $pluginInfo->sections['description'] = $fileHeader['Description'];
  355. }
  356. }
  357. protected function parseReadme($content) {
  358. if ( !class_exists('PucReadmeParser', false) ) {
  359. require_once(dirname(__FILE__) . '/vendor/readme-parser.php');
  360. }
  361. $parser = new PucReadmeParser();
  362. return $parser->parse_readme_contents($content);
  363. }
  364. /**
  365. * Check if the currently installed version has a readme.txt file.
  366. *
  367. * @return bool
  368. */
  369. protected function readmeTxtExistsLocally() {
  370. $pluginDirectory = dirname($this->pluginAbsolutePath);
  371. if ( empty($this->pluginAbsolutePath) || !is_dir($pluginDirectory) || ($pluginDirectory === '.') ) {
  372. return false;
  373. }
  374. return is_file($pluginDirectory . '/readme.txt');
  375. }
  376. /**
  377. * Generate a URL to download a ZIP archive of the specified branch/tag/etc.
  378. *
  379. * @param string $ref
  380. * @return string
  381. */
  382. protected function buildArchiveDownloadUrl($ref = 'master') {
  383. $url = sprintf(
  384. 'https://api.github.com/repos/%1$s/%2$s/zipball/%3$s',
  385. urlencode($this->userName),
  386. urlencode($this->repositoryName),
  387. urlencode($ref)
  388. );
  389. if ( !empty($this->accessToken) ) {
  390. $url = add_query_arg('access_token', $this->accessToken, $url);
  391. }
  392. return $url;
  393. }
  394. }
  395. endif;