GitLabApi.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414
  1. <?php
  2. namespace YahnisElsts\PluginUpdateChecker\v5p1\Vcs;
  3. if ( !class_exists(GitLabApi::class, false) ):
  4. class GitLabApi extends Api {
  5. use ReleaseAssetSupport;
  6. use ReleaseFilteringFeature;
  7. /**
  8. * @var string GitLab username.
  9. */
  10. protected $userName;
  11. /**
  12. * @var string GitLab server host.
  13. */
  14. protected $repositoryHost;
  15. /**
  16. * @var string Protocol used by this GitLab server: "http" or "https".
  17. */
  18. protected $repositoryProtocol = 'https';
  19. /**
  20. * @var string GitLab repository name.
  21. */
  22. protected $repositoryName;
  23. /**
  24. * @var string GitLab authentication token. Optional.
  25. */
  26. protected $accessToken;
  27. /**
  28. * @deprecated
  29. * @var bool No longer used.
  30. */
  31. protected $releasePackageEnabled = false;
  32. public function __construct($repositoryUrl, $accessToken = null, $subgroup = null) {
  33. //Parse the repository host to support custom hosts.
  34. $port = wp_parse_url($repositoryUrl, PHP_URL_PORT);
  35. if ( !empty($port) ) {
  36. $port = ':' . $port;
  37. }
  38. $this->repositoryHost = wp_parse_url($repositoryUrl, PHP_URL_HOST) . $port;
  39. if ( $this->repositoryHost !== 'gitlab.com' ) {
  40. $this->repositoryProtocol = wp_parse_url($repositoryUrl, PHP_URL_SCHEME);
  41. }
  42. //Find the repository information
  43. $path = wp_parse_url($repositoryUrl, PHP_URL_PATH);
  44. if ( preg_match('@^/?(?P<username>[^/]+?)/(?P<repository>[^/#?&]+?)/?$@', $path, $matches) ) {
  45. $this->userName = $matches['username'];
  46. $this->repositoryName = $matches['repository'];
  47. } elseif ( ($this->repositoryHost === 'gitlab.com') ) {
  48. //This is probably a repository in a subgroup, e.g. "/organization/category/repo".
  49. $parts = explode('/', trim($path, '/'));
  50. if ( count($parts) < 3 ) {
  51. throw new \InvalidArgumentException('Invalid GitLab.com repository URL: "' . $repositoryUrl . '"');
  52. }
  53. $lastPart = array_pop($parts);
  54. $this->userName = implode('/', $parts);
  55. $this->repositoryName = $lastPart;
  56. } else {
  57. //There could be subgroups in the URL: gitlab.domain.com/group/subgroup/subgroup2/repository
  58. if ( $subgroup !== null ) {
  59. $path = str_replace(trailingslashit($subgroup), '', $path);
  60. }
  61. //This is not a traditional url, it could be gitlab is in a deeper subdirectory.
  62. //Get the path segments.
  63. $segments = explode('/', untrailingslashit(ltrim($path, '/')));
  64. //We need at least /user-name/repository-name/
  65. if ( count($segments) < 2 ) {
  66. throw new \InvalidArgumentException('Invalid GitLab repository URL: "' . $repositoryUrl . '"');
  67. }
  68. //Get the username and repository name.
  69. $usernameRepo = array_splice($segments, -2, 2);
  70. $this->userName = $usernameRepo[0];
  71. $this->repositoryName = $usernameRepo[1];
  72. //Append the remaining segments to the host if there are segments left.
  73. if ( count($segments) > 0 ) {
  74. $this->repositoryHost = trailingslashit($this->repositoryHost) . implode('/', $segments);
  75. }
  76. //Add subgroups to username.
  77. if ( $subgroup !== null ) {
  78. $this->userName = $usernameRepo[0] . '/' . untrailingslashit($subgroup);
  79. }
  80. }
  81. parent::__construct($repositoryUrl, $accessToken);
  82. }
  83. /**
  84. * Get the latest release from GitLab.
  85. *
  86. * @return Reference|null
  87. */
  88. public function getLatestRelease() {
  89. $releases = $this->api('/:id/releases', array('per_page' => $this->releaseFilterMaxReleases));
  90. if ( is_wp_error($releases) || empty($releases) || !is_array($releases) ) {
  91. return null;
  92. }
  93. foreach ($releases as $release) {
  94. if (
  95. //Skip invalid/unsupported releases.
  96. !is_object($release)
  97. || !isset($release->tag_name)
  98. //Skip upcoming releases.
  99. || (
  100. !empty($release->upcoming_release)
  101. && $this->shouldSkipPreReleases()
  102. )
  103. ) {
  104. continue;
  105. }
  106. $versionNumber = ltrim($release->tag_name, 'v'); //Remove the "v" prefix from "v1.2.3".
  107. //Apply custom filters.
  108. if ( !$this->matchesCustomReleaseFilter($versionNumber, $release) ) {
  109. continue;
  110. }
  111. $downloadUrl = $this->findReleaseDownloadUrl($release);
  112. if ( empty($downloadUrl) ) {
  113. //The latest release doesn't have valid download URL.
  114. return null;
  115. }
  116. if ( !empty($this->accessToken) ) {
  117. $downloadUrl = add_query_arg('private_token', $this->accessToken, $downloadUrl);
  118. }
  119. return new Reference(array(
  120. 'name' => $release->tag_name,
  121. 'version' => $versionNumber,
  122. 'downloadUrl' => $downloadUrl,
  123. 'updated' => $release->released_at,
  124. 'apiResponse' => $release,
  125. ));
  126. }
  127. return null;
  128. }
  129. /**
  130. * @param object $release
  131. * @return string|null
  132. */
  133. protected function findReleaseDownloadUrl($release) {
  134. if ( $this->releaseAssetsEnabled ) {
  135. if ( isset($release->assets, $release->assets->links) ) {
  136. //Use the first asset link where the URL matches the filter.
  137. foreach ($release->assets->links as $link) {
  138. if ( $this->matchesAssetFilter($link) ) {
  139. return $link->url;
  140. }
  141. }
  142. }
  143. if ( $this->releaseAssetPreference === Api::REQUIRE_RELEASE_ASSETS ) {
  144. //Falling back to source archives is not allowed, so give up.
  145. return null;
  146. }
  147. }
  148. //Use the first source code archive that's in ZIP format.
  149. foreach ($release->assets->sources as $source) {
  150. if ( isset($source->format) && ($source->format === 'zip') ) {
  151. return $source->url;
  152. }
  153. }
  154. return null;
  155. }
  156. /**
  157. * Get the tag that looks like the highest version number.
  158. *
  159. * @return Reference|null
  160. */
  161. public function getLatestTag() {
  162. $tags = $this->api('/:id/repository/tags');
  163. if ( is_wp_error($tags) || empty($tags) || !is_array($tags) ) {
  164. return null;
  165. }
  166. $versionTags = $this->sortTagsByVersion($tags);
  167. if ( empty($versionTags) ) {
  168. return null;
  169. }
  170. $tag = $versionTags[0];
  171. return new Reference(array(
  172. 'name' => $tag->name,
  173. 'version' => ltrim($tag->name, 'v'),
  174. 'downloadUrl' => $this->buildArchiveDownloadUrl($tag->name),
  175. 'apiResponse' => $tag,
  176. ));
  177. }
  178. /**
  179. * Get a branch by name.
  180. *
  181. * @param string $branchName
  182. * @return null|Reference
  183. */
  184. public function getBranch($branchName) {
  185. $branch = $this->api('/:id/repository/branches/' . $branchName);
  186. if ( is_wp_error($branch) || empty($branch) ) {
  187. return null;
  188. }
  189. $reference = new Reference(array(
  190. 'name' => $branch->name,
  191. 'downloadUrl' => $this->buildArchiveDownloadUrl($branch->name),
  192. 'apiResponse' => $branch,
  193. ));
  194. if ( isset($branch->commit, $branch->commit->committed_date) ) {
  195. $reference->updated = $branch->commit->committed_date;
  196. }
  197. return $reference;
  198. }
  199. /**
  200. * Get the timestamp of the latest commit that changed the specified branch or tag.
  201. *
  202. * @param string $ref Reference name (e.g. branch or tag).
  203. * @return string|null
  204. */
  205. public function getLatestCommitTime($ref) {
  206. $commits = $this->api('/:id/repository/commits/', array('ref_name' => $ref));
  207. if ( is_wp_error($commits) || !is_array($commits) || !isset($commits[0]) ) {
  208. return null;
  209. }
  210. return $commits[0]->committed_date;
  211. }
  212. /**
  213. * Perform a GitLab API request.
  214. *
  215. * @param string $url
  216. * @param array $queryParams
  217. * @return mixed|\WP_Error
  218. */
  219. protected function api($url, $queryParams = array()) {
  220. $baseUrl = $url;
  221. $url = $this->buildApiUrl($url, $queryParams);
  222. $options = array('timeout' => wp_doing_cron() ? 10 : 3);
  223. if ( !empty($this->httpFilterName) ) {
  224. $options = apply_filters($this->httpFilterName, $options);
  225. }
  226. $response = wp_remote_get($url, $options);
  227. if ( is_wp_error($response) ) {
  228. do_action('puc_api_error', $response, null, $url, $this->slug);
  229. return $response;
  230. }
  231. $code = wp_remote_retrieve_response_code($response);
  232. $body = wp_remote_retrieve_body($response);
  233. if ( $code === 200 ) {
  234. return json_decode($body);
  235. }
  236. $error = new \WP_Error(
  237. 'puc-gitlab-http-error',
  238. sprintf('GitLab API error. URL: "%s", HTTP status code: %d.', $baseUrl, $code)
  239. );
  240. do_action('puc_api_error', $error, $response, $url, $this->slug);
  241. return $error;
  242. }
  243. /**
  244. * Build a fully qualified URL for an API request.
  245. *
  246. * @param string $url
  247. * @param array $queryParams
  248. * @return string
  249. */
  250. protected function buildApiUrl($url, $queryParams) {
  251. $variables = array(
  252. 'user' => $this->userName,
  253. 'repo' => $this->repositoryName,
  254. 'id' => $this->userName . '/' . $this->repositoryName,
  255. );
  256. foreach ($variables as $name => $value) {
  257. $url = str_replace("/:{$name}", '/' . urlencode($value), $url);
  258. }
  259. $url = substr($url, 1);
  260. $url = sprintf('%1$s://%2$s/api/v4/projects/%3$s', $this->repositoryProtocol, $this->repositoryHost, $url);
  261. if ( !empty($this->accessToken) ) {
  262. $queryParams['private_token'] = $this->accessToken;
  263. }
  264. if ( !empty($queryParams) ) {
  265. $url = add_query_arg($queryParams, $url);
  266. }
  267. return $url;
  268. }
  269. /**
  270. * Get the contents of a file from a specific branch or tag.
  271. *
  272. * @param string $path File name.
  273. * @param string $ref
  274. * @return null|string Either the contents of the file, or null if the file doesn't exist or there's an error.
  275. */
  276. public function getRemoteFile($path, $ref = 'master') {
  277. $response = $this->api('/:id/repository/files/' . $path, array('ref' => $ref));
  278. if ( is_wp_error($response) || !isset($response->content) || $response->encoding !== 'base64' ) {
  279. return null;
  280. }
  281. return base64_decode($response->content);
  282. }
  283. /**
  284. * Generate a URL to download a ZIP archive of the specified branch/tag/etc.
  285. *
  286. * @param string $ref
  287. * @return string
  288. */
  289. public function buildArchiveDownloadUrl($ref = 'master') {
  290. $url = sprintf(
  291. '%1$s://%2$s/api/v4/projects/%3$s/repository/archive.zip',
  292. $this->repositoryProtocol,
  293. $this->repositoryHost,
  294. urlencode($this->userName . '/' . $this->repositoryName)
  295. );
  296. $url = add_query_arg('sha', urlencode($ref), $url);
  297. if ( !empty($this->accessToken) ) {
  298. $url = add_query_arg('private_token', $this->accessToken, $url);
  299. }
  300. return $url;
  301. }
  302. /**
  303. * Get a specific tag.
  304. *
  305. * @param string $tagName
  306. * @return void
  307. */
  308. public function getTag($tagName) {
  309. throw new \LogicException('The ' . __METHOD__ . ' method is not implemented and should not be used.');
  310. }
  311. protected function getUpdateDetectionStrategies($configBranch) {
  312. $strategies = array();
  313. if ( ($configBranch === 'main') || ($configBranch === 'master') ) {
  314. $strategies[self::STRATEGY_LATEST_RELEASE] = array($this, 'getLatestRelease');
  315. $strategies[self::STRATEGY_LATEST_TAG] = array($this, 'getLatestTag');
  316. }
  317. $strategies[self::STRATEGY_BRANCH] = function () use ($configBranch) {
  318. return $this->getBranch($configBranch);
  319. };
  320. return $strategies;
  321. }
  322. public function setAuthentication($credentials) {
  323. parent::setAuthentication($credentials);
  324. $this->accessToken = is_string($credentials) ? $credentials : null;
  325. }
  326. /**
  327. * Use release assets that link to GitLab generic packages (e.g. .zip files)
  328. * instead of automatically generated source archives.
  329. *
  330. * This is included for backwards compatibility with older versions of PUC.
  331. *
  332. * @return void
  333. * @deprecated Use enableReleaseAssets() instead.
  334. * @noinspection PhpUnused -- Public API
  335. */
  336. public function enableReleasePackages() {
  337. $this->enableReleaseAssets(
  338. /** @lang RegExp */ '/\.zip($|[?&#])/i',
  339. Api::REQUIRE_RELEASE_ASSETS
  340. );
  341. }
  342. protected function getFilterableAssetName($releaseAsset) {
  343. if ( isset($releaseAsset->url) ) {
  344. return $releaseAsset->url;
  345. }
  346. return null;
  347. }
  348. }
  349. endif;