PageRenderTime 56ms CodeModel.GetById 24ms RepoModel.GetById 1ms app.codeStats 0ms

/src/Composer/Repository/Vcs/GitLabDriver.php

http://github.com/composer/composer
PHP | 582 lines | 348 code | 100 blank | 134 comment | 70 complexity | f251ba08b35826cc89f3308f121663b9 MD5 | raw file
  1. <?php
  2. /*
  3. * This file is part of Composer.
  4. *
  5. * (c) Nils Adermann <naderman@naderman.de>
  6. * Jordi Boggiano <j.boggiano@seld.be>
  7. *
  8. * For the full copyright and license information, please view the LICENSE
  9. * file that was distributed with this source code.
  10. */
  11. namespace Composer\Repository\Vcs;
  12. use Composer\Config;
  13. use Composer\Cache;
  14. use Composer\IO\IOInterface;
  15. use Composer\Json\JsonFile;
  16. use Composer\Downloader\TransportException;
  17. use Composer\Util\HttpDownloader;
  18. use Composer\Util\GitLab;
  19. use Composer\Util\Http\Response;
  20. /**
  21. * Driver for GitLab API, use the Git driver for local checkouts.
  22. *
  23. * @author Henrik Bjørnskov <henrik@bjrnskov.dk>
  24. * @author Jérôme Tamarelle <jerome@tamarelle.net>
  25. */
  26. class GitLabDriver extends VcsDriver
  27. {
  28. private $scheme;
  29. private $namespace;
  30. private $repository;
  31. /**
  32. * @var array Project data returned by GitLab API
  33. */
  34. private $project;
  35. /**
  36. * @var array Keeps commits returned by GitLab API
  37. */
  38. private $commits = array();
  39. /**
  40. * @var array List of tag => reference
  41. */
  42. private $tags;
  43. /**
  44. * @var array List of branch => reference
  45. */
  46. private $branches;
  47. /**
  48. * Git Driver
  49. *
  50. * @var GitDriver
  51. */
  52. protected $gitDriver;
  53. /**
  54. * Defaults to true unless we can make sure it is public
  55. *
  56. * @var bool defines whether the repo is private or not
  57. */
  58. private $isPrivate = true;
  59. /**
  60. * @var bool true if the origin has a port number or a path component in it
  61. */
  62. private $hasNonstandardOrigin = false;
  63. const URL_REGEX = '#^(?:(?P<scheme>https?)://(?P<domain>.+?)(?::(?P<port>[0-9]+))?/|git@(?P<domain2>[^:]+):)(?P<parts>.+)/(?P<repo>[^/]+?)(?:\.git|/)?$#';
  64. /**
  65. * Extracts information from the repository url.
  66. *
  67. * SSH urls use https by default. Set "secure-http": false on the repository config to use http instead.
  68. *
  69. * {@inheritDoc}
  70. */
  71. public function initialize()
  72. {
  73. if (!preg_match(self::URL_REGEX, $this->url, $match)) {
  74. throw new \InvalidArgumentException('The URL provided is invalid. It must be the HTTP URL of a GitLab project.');
  75. }
  76. $guessedDomain = !empty($match['domain']) ? $match['domain'] : $match['domain2'];
  77. $configuredDomains = $this->config->get('gitlab-domains');
  78. $urlParts = explode('/', $match['parts']);
  79. $this->scheme = !empty($match['scheme'])
  80. ? $match['scheme']
  81. : (isset($this->repoConfig['secure-http']) && $this->repoConfig['secure-http'] === false ? 'http' : 'https')
  82. ;
  83. $this->originUrl = $this->determineOrigin($configuredDomains, $guessedDomain, $urlParts, $match['port']);
  84. if (false !== strpos($this->originUrl, ':') || false !== strpos($this->originUrl, '/')) {
  85. $this->hasNonstandardOrigin = true;
  86. }
  87. $this->namespace = implode('/', $urlParts);
  88. $this->repository = preg_replace('#(\.git)$#', '', $match['repo']);
  89. $this->cache = new Cache($this->io, $this->config->get('cache-repo-dir').'/'.$this->originUrl.'/'.$this->namespace.'/'.$this->repository);
  90. $this->fetchProject();
  91. }
  92. /**
  93. * Updates the HttpDownloader instance.
  94. * Mainly useful for tests.
  95. *
  96. * @internal
  97. */
  98. public function setHttpDownloader(HttpDownloader $httpDownloader)
  99. {
  100. $this->httpDownloader = $httpDownloader;
  101. }
  102. /**
  103. * {@inheritDoc}
  104. */
  105. public function getComposerInformation($identifier)
  106. {
  107. if ($this->gitDriver) {
  108. return $this->gitDriver->getComposerInformation($identifier);
  109. }
  110. if (!isset($this->infoCache[$identifier])) {
  111. if ($this->shouldCache($identifier) && $res = $this->cache->read($identifier)) {
  112. return $this->infoCache[$identifier] = JsonFile::parseJson($res);
  113. }
  114. $composer = $this->getBaseComposerInformation($identifier);
  115. if ($composer) {
  116. // specials for gitlab (this data is only available if authentication is provided)
  117. if (!isset($composer['support']['issues']) && isset($this->project['_links']['issues'])) {
  118. $composer['support']['issues'] = $this->project['_links']['issues'];
  119. }
  120. if (!isset($composer['abandoned']) && !empty($this->project['archived'])) {
  121. $composer['abandoned'] = true;
  122. }
  123. }
  124. if ($this->shouldCache($identifier)) {
  125. $this->cache->write($identifier, json_encode($composer));
  126. }
  127. $this->infoCache[$identifier] = $composer;
  128. }
  129. return $this->infoCache[$identifier];
  130. }
  131. /**
  132. * {@inheritdoc}
  133. */
  134. public function getFileContent($file, $identifier)
  135. {
  136. if ($this->gitDriver) {
  137. return $this->gitDriver->getFileContent($file, $identifier);
  138. }
  139. // Convert the root identifier to a cacheable commit id
  140. if (!preg_match('{[a-f0-9]{40}}i', $identifier)) {
  141. $branches = $this->getBranches();
  142. if (isset($branches[$identifier])) {
  143. $identifier = $branches[$identifier];
  144. }
  145. }
  146. $resource = $this->getApiUrl().'/repository/files/'.$this->urlEncodeAll($file).'/raw?ref='.$identifier;
  147. try {
  148. $content = $this->getContents($resource)->getBody();
  149. } catch (TransportException $e) {
  150. if ($e->getCode() !== 404) {
  151. throw $e;
  152. }
  153. return null;
  154. }
  155. return $content;
  156. }
  157. /**
  158. * {@inheritdoc}
  159. */
  160. public function getChangeDate($identifier)
  161. {
  162. if ($this->gitDriver) {
  163. return $this->gitDriver->getChangeDate($identifier);
  164. }
  165. if (isset($this->commits[$identifier])) {
  166. return new \DateTime($this->commits[$identifier]['committed_date']);
  167. }
  168. return new \DateTime();
  169. }
  170. /**
  171. * {@inheritDoc}
  172. */
  173. public function getRepositoryUrl()
  174. {
  175. return $this->isPrivate ? $this->project['ssh_url_to_repo'] : $this->project['http_url_to_repo'];
  176. }
  177. /**
  178. * {@inheritDoc}
  179. */
  180. public function getUrl()
  181. {
  182. if ($this->gitDriver) {
  183. return $this->gitDriver->getUrl();
  184. }
  185. return $this->project['web_url'];
  186. }
  187. /**
  188. * {@inheritDoc}
  189. */
  190. public function getDist($identifier)
  191. {
  192. $url = $this->getApiUrl().'/repository/archive.zip?sha='.$identifier;
  193. return array('type' => 'zip', 'url' => $url, 'reference' => $identifier, 'shasum' => '');
  194. }
  195. /**
  196. * {@inheritDoc}
  197. */
  198. public function getSource($identifier)
  199. {
  200. if ($this->gitDriver) {
  201. return $this->gitDriver->getSource($identifier);
  202. }
  203. return array('type' => 'git', 'url' => $this->getRepositoryUrl(), 'reference' => $identifier);
  204. }
  205. /**
  206. * {@inheritDoc}
  207. */
  208. public function getRootIdentifier()
  209. {
  210. if ($this->gitDriver) {
  211. return $this->gitDriver->getRootIdentifier();
  212. }
  213. return $this->project['default_branch'];
  214. }
  215. /**
  216. * {@inheritDoc}
  217. */
  218. public function getBranches()
  219. {
  220. if ($this->gitDriver) {
  221. return $this->gitDriver->getBranches();
  222. }
  223. if (!$this->branches) {
  224. $this->branches = $this->getReferences('branches');
  225. }
  226. return $this->branches;
  227. }
  228. /**
  229. * {@inheritDoc}
  230. */
  231. public function getTags()
  232. {
  233. if ($this->gitDriver) {
  234. return $this->gitDriver->getTags();
  235. }
  236. if (!$this->tags) {
  237. $this->tags = $this->getReferences('tags');
  238. }
  239. return $this->tags;
  240. }
  241. /**
  242. * @return string Base URL for GitLab API v3
  243. */
  244. public function getApiUrl()
  245. {
  246. return $this->scheme.'://'.$this->originUrl.'/api/v4/projects/'.$this->urlEncodeAll($this->namespace).'%2F'.$this->urlEncodeAll($this->repository);
  247. }
  248. /**
  249. * Urlencode all non alphanumeric characters. rawurlencode() can not be used as it does not encode `.`
  250. *
  251. * @param string $string
  252. * @return string
  253. */
  254. private function urlEncodeAll($string)
  255. {
  256. $encoded = '';
  257. for ($i = 0; isset($string[$i]); $i++) {
  258. $character = $string[$i];
  259. if (!ctype_alnum($character) && !in_array($character, array('-', '_'), true)) {
  260. $character = '%' . sprintf('%02X', ord($character));
  261. }
  262. $encoded .= $character;
  263. }
  264. return $encoded;
  265. }
  266. /**
  267. * @param string $type
  268. *
  269. * @return string[] where keys are named references like tags or branches and the value a sha
  270. */
  271. protected function getReferences($type)
  272. {
  273. $perPage = 100;
  274. $resource = $this->getApiUrl().'/repository/'.$type.'?per_page='.$perPage;
  275. $references = array();
  276. do {
  277. $response = $this->getContents($resource);
  278. $data = $response->decodeJson();
  279. foreach ($data as $datum) {
  280. $references[$datum['name']] = $datum['commit']['id'];
  281. // Keep the last commit date of a reference to avoid
  282. // unnecessary API call when retrieving the composer file.
  283. $this->commits[$datum['commit']['id']] = $datum['commit'];
  284. }
  285. if (count($data) >= $perPage) {
  286. $resource = $this->getNextPage($response);
  287. } else {
  288. $resource = false;
  289. }
  290. } while ($resource);
  291. return $references;
  292. }
  293. protected function fetchProject()
  294. {
  295. // we need to fetch the default branch from the api
  296. $resource = $this->getApiUrl();
  297. $this->project = $this->getContents($resource, true)->decodeJson();
  298. if (isset($this->project['visibility'])) {
  299. $this->isPrivate = $this->project['visibility'] !== 'public';
  300. } else {
  301. // client is not authendicated, therefore repository has to be public
  302. $this->isPrivate = false;
  303. }
  304. }
  305. protected function attemptCloneFallback()
  306. {
  307. if ($this->isPrivate === false) {
  308. $url = $this->generatePublicUrl();
  309. } else {
  310. $url = $this->generateSshUrl();
  311. }
  312. try {
  313. // If this repository may be private and we
  314. // cannot ask for authentication credentials (because we
  315. // are not interactive) then we fallback to GitDriver.
  316. $this->setupGitDriver($url);
  317. return true;
  318. } catch (\RuntimeException $e) {
  319. $this->gitDriver = null;
  320. $this->io->writeError('<error>Failed to clone the '.$url.' repository, try running in interactive mode so that you can enter your credentials</error>');
  321. throw $e;
  322. }
  323. }
  324. /**
  325. * Generate an SSH URL
  326. *
  327. * @return string
  328. */
  329. protected function generateSshUrl()
  330. {
  331. if ($this->hasNonstandardOrigin) {
  332. return 'ssh://git@'.$this->originUrl.'/'.$this->namespace.'/'.$this->repository.'.git';
  333. }
  334. return 'git@' . $this->originUrl . ':'.$this->namespace.'/'.$this->repository.'.git';
  335. }
  336. protected function generatePublicUrl()
  337. {
  338. return $this->scheme . '://' . $this->originUrl . '/'.$this->namespace.'/'.$this->repository.'.git';
  339. }
  340. protected function setupGitDriver($url)
  341. {
  342. $this->gitDriver = new GitDriver(
  343. array('url' => $url),
  344. $this->io,
  345. $this->config,
  346. $this->httpDownloader,
  347. $this->process
  348. );
  349. $this->gitDriver->initialize();
  350. }
  351. /**
  352. * {@inheritDoc}
  353. */
  354. protected function getContents($url, $fetchingRepoData = false)
  355. {
  356. try {
  357. $response = parent::getContents($url);
  358. if ($fetchingRepoData) {
  359. $json = $response->decodeJson();
  360. // Accessing the API with a token with Guest (10) access will return
  361. // more data than unauthenticated access but no default_branch data
  362. // accessing files via the API will then also fail
  363. if (!isset($json['default_branch']) && isset($json['permissions'])) {
  364. $this->isPrivate = $json['visibility'] !== 'public';
  365. $moreThanGuestAccess = false;
  366. // Check both access levels (e.g. project, group)
  367. // - value will be null if no access is set
  368. // - value will be array with key access_level if set
  369. foreach ($json['permissions'] as $permission) {
  370. if ($permission && $permission['access_level'] > 10) {
  371. $moreThanGuestAccess = true;
  372. }
  373. }
  374. if (!$moreThanGuestAccess) {
  375. $this->io->writeError('<warning>GitLab token with Guest only access detected</warning>');
  376. return $this->attemptCloneFallback();
  377. }
  378. }
  379. // force auth as the unauthenticated version of the API is broken
  380. if (!isset($json['default_branch'])) {
  381. if (!empty($json['id'])) {
  382. $this->isPrivate = false;
  383. }
  384. throw new TransportException('GitLab API seems to not be authenticated as it did not return a default_branch', 401);
  385. }
  386. }
  387. return $response;
  388. } catch (TransportException $e) {
  389. $gitLabUtil = new GitLab($this->io, $this->config, $this->process, $this->httpDownloader);
  390. switch ($e->getCode()) {
  391. case 401:
  392. case 404:
  393. // try to authorize only if we are fetching the main /repos/foo/bar data, otherwise it must be a real 404
  394. if (!$fetchingRepoData) {
  395. throw $e;
  396. }
  397. if ($gitLabUtil->authorizeOAuth($this->originUrl)) {
  398. return parent::getContents($url);
  399. }
  400. if (!$this->io->isInteractive()) {
  401. if ($this->attemptCloneFallback()) {
  402. return new Response(array('url' => 'dummy'), 200, array(), 'null');
  403. }
  404. }
  405. $this->io->writeError('<warning>Failed to download ' . $this->namespace . '/' . $this->repository . ':' . $e->getMessage() . '</warning>');
  406. $gitLabUtil->authorizeOAuthInteractively($this->scheme, $this->originUrl, 'Your credentials are required to fetch private repository metadata (<info>'.$this->url.'</info>)');
  407. return parent::getContents($url);
  408. case 403:
  409. if (!$this->io->hasAuthentication($this->originUrl) && $gitLabUtil->authorizeOAuth($this->originUrl)) {
  410. return parent::getContents($url);
  411. }
  412. if (!$this->io->isInteractive() && $fetchingRepoData) {
  413. if ($this->attemptCloneFallback()) {
  414. return new Response(array('url' => 'dummy'), 200, array(), 'null');
  415. }
  416. }
  417. throw $e;
  418. default:
  419. throw $e;
  420. }
  421. }
  422. }
  423. /**
  424. * Uses the config `gitlab-domains` to see if the driver supports the url for the
  425. * repository given.
  426. *
  427. * {@inheritDoc}
  428. */
  429. public static function supports(IOInterface $io, Config $config, $url, $deep = false)
  430. {
  431. if (!preg_match(self::URL_REGEX, $url, $match)) {
  432. return false;
  433. }
  434. $scheme = !empty($match['scheme']) ? $match['scheme'] : null;
  435. $guessedDomain = !empty($match['domain']) ? $match['domain'] : $match['domain2'];
  436. $urlParts = explode('/', $match['parts']);
  437. if (false === self::determineOrigin((array) $config->get('gitlab-domains'), $guessedDomain, $urlParts, $match['port'])) {
  438. return false;
  439. }
  440. if ('https' === $scheme && !extension_loaded('openssl')) {
  441. $io->writeError('Skipping GitLab driver for '.$url.' because the OpenSSL PHP extension is missing.', true, IOInterface::VERBOSE);
  442. return false;
  443. }
  444. return true;
  445. }
  446. protected function getNextPage(Response $response)
  447. {
  448. $header = $response->getHeader('link');
  449. $links = explode(',', $header);
  450. foreach ($links as $link) {
  451. if (preg_match('{<(.+?)>; *rel="next"}', $link, $match)) {
  452. return $match[1];
  453. }
  454. }
  455. }
  456. /**
  457. * @param array $configuredDomains
  458. * @param string $guessedDomain
  459. * @param array $urlParts
  460. * @return bool|string
  461. */
  462. private static function determineOrigin(array $configuredDomains, $guessedDomain, array &$urlParts, $portNumber)
  463. {
  464. $guessedDomain = strtolower($guessedDomain);
  465. if (in_array($guessedDomain, $configuredDomains) || ($portNumber && in_array($guessedDomain.':'.$portNumber, $configuredDomains))) {
  466. if ($portNumber) {
  467. return $guessedDomain.':'.$portNumber;
  468. }
  469. return $guessedDomain;
  470. }
  471. if ($portNumber) {
  472. $guessedDomain .= ':'.$portNumber;
  473. }
  474. while (null !== ($part = array_shift($urlParts))) {
  475. $guessedDomain .= '/' . $part;
  476. if (in_array($guessedDomain, $configuredDomains) || ($portNumber && in_array(preg_replace('{:\d+}', '', $guessedDomain), $configuredDomains))) {
  477. return $guessedDomain;
  478. }
  479. }
  480. return false;
  481. }
  482. }