diff --git a/src/Composer/Downloader/GitDownloader.php b/src/Composer/Downloader/GitDownloader.php
index 3162e62cb..c7a829f5e 100644
--- a/src/Composer/Downloader/GitDownloader.php
+++ b/src/Composer/Downloader/GitDownloader.php
@@ -51,10 +51,12 @@ class GitDownloader extends VcsDownloader
$this->io->write(" Checking out ".$ref);
$command = 'cd %s && git remote set-url composer %s && git fetch composer && git fetch --tags composer';
- // capture username/password from github URL if there is one
- $this->process->execute(sprintf('cd %s && git remote -v', escapeshellarg($path)), $output);
- if (preg_match('{^composer\s+https://(.+):(.+)@github.com/}im', $output, $match)) {
- $this->io->setAuthorization('github.com', $match[1], $match[2]);
+ if (!$this->io->hasAuthorization('github.com')) {
+ // capture username/password from github URL if there is one
+ $this->process->execute(sprintf('cd %s && git remote -v', escapeshellarg($path)), $output);
+ if (preg_match('{^composer\s+https://(.+):(.+)@github.com/}im', $output, $match)) {
+ $this->io->setAuthorization('github.com', $match[1], $match[2]);
+ }
}
$commandCallable = function($url) use ($ref, $path, $command) {
@@ -327,7 +329,7 @@ class GitDownloader extends VcsDownloader
protected function sanitizeUrl($message)
{
- return preg_match('{://(.+?):.+?@}', '://$1:***@', $message);
+ return preg_replace('{://(.+?):.+?@}', '://$1:***@', $message);
}
protected function setPushUrl(PackageInterface $package, $path)
diff --git a/src/Composer/Downloader/TransportException.php b/src/Composer/Downloader/TransportException.php
index 61bd67d11..d157dde3c 100644
--- a/src/Composer/Downloader/TransportException.php
+++ b/src/Composer/Downloader/TransportException.php
@@ -17,4 +17,15 @@ namespace Composer\Downloader;
*/
class TransportException extends \Exception
{
+ protected $headers;
+
+ public function setHeaders($headers)
+ {
+ $this->headers = $headers;
+ }
+
+ public function getHeaders()
+ {
+ return $this->headers;
+ }
}
diff --git a/src/Composer/Repository/Vcs/GitHubDriver.php b/src/Composer/Repository/Vcs/GitHubDriver.php
index 5d1965d10..b5f8274bf 100755
--- a/src/Composer/Repository/Vcs/GitHubDriver.php
+++ b/src/Composer/Repository/Vcs/GitHubDriver.php
@@ -235,6 +235,62 @@ class GitHubDriver extends VcsDriver
return 'git@github.com:'.$this->owner.'/'.$this->repository.'.git';
}
+ /**
+ * {@inheritDoc}
+ */
+ protected function getContents($url, $tryClone = false)
+ {
+ try {
+ return parent::getContents($url);
+ } catch (TransportException $e) {
+ switch ($e->getCode()) {
+ case 401:
+ case 404:
+ if (!$this->io->isInteractive() && $tryClone) {
+ return $this->attemptCloneFallback($e);
+ }
+
+ $this->io->write('Your GitHub credentials are required to fetch private repository metadata ('.$this->url.'):');
+ $this->authorizeOauth();
+
+ return parent::getContents($url);
+
+ case 403:
+ if (!$this->io->isInteractive() && $tryClone) {
+ return $this->attemptCloneFallback($e);
+ }
+
+ $rateLimited = false;
+ foreach ($e->getHeaders() as $header) {
+ if (preg_match('{^X-RateLimit-Remaining: *0$}i', trim($header))) {
+ $rateLimited = true;
+ }
+ }
+
+ if (!$this->io->hasAuthorization($this->originUrl)) {
+ if (!$this->io->isInteractive()) {
+ $this->io->write('GitHub API limit exhausted. Failed to get metadata for the '.$this->url.' repository, try running in interactive mode so that you can enter your GitHub credentials to increase the API limit');
+ throw $e;
+ }
+
+ $this->io->write('API limit exhausted. Enter your GitHub credentials to get a larger API limit ('.$this->url.'):');
+ $this->authorizeOauth();
+
+ return parent::getContents($url);
+ }
+
+ if ($rateLimited) {
+ $this->io->write('GitHub API limit exhausted. You are already authorized so you will have to wait a while before doing more requests');
+ }
+
+ throw $e;
+
+ default:
+ throw $e;
+ }
+ }
+ }
+
/**
* Fetch root identifier from GitHub
*
@@ -243,73 +299,83 @@ class GitHubDriver extends VcsDriver
protected function fetchRootIdentifier()
{
$repoDataUrl = 'https://api.github.com/repos/'.$this->owner.'/'.$this->repository;
- $attemptCounter = 0;
- while (null === $this->rootIdentifier) {
- if (5 == $attemptCounter++) {
- throw new \RuntimeException("Either you have entered invalid credentials or this GitHub repository does not exists (404)");
- }
- try {
- $repoData = JsonFile::parseJson($this->getContents($repoDataUrl), $repoDataUrl);
- if (isset($repoData['default_branch'])) {
- $this->rootIdentifier = $repoData['default_branch'];
- } elseif (isset($repoData['master_branch'])) {
- $this->rootIdentifier = $repoData['master_branch'];
- } else {
- $this->rootIdentifier = 'master';
- }
- $this->hasIssues = !empty($repoData['has_issues']);
- } catch (TransportException $e) {
- switch ($e->getCode()) {
- case 401:
- case 404:
- $this->isPrivate = true;
- try {
- // If this repository may be private (hard to say for sure,
- // GitHub returns 404 for private repositories) and we
- // cannot ask for authentication credentials (because we
- // are not interactive) then we fallback to GitDriver.
- $this->gitDriver = new GitDriver(
- $this->generateSshUrl(),
- $this->io,
- $this->config,
- $this->process,
- $this->remoteFilesystem
- );
- $this->gitDriver->initialize();
+ $repoData = JsonFile::parseJson($this->getContents($repoDataUrl, true), $repoDataUrl);
+ if (null === $repoData && null !== $this->gitDriver) {
+ return;
+ }
- return;
- } catch (\RuntimeException $e) {
- $this->gitDriver = null;
- if (!$this->io->isInteractive()) {
- $this->io->write('Failed to clone the '.$this->generateSshUrl().' repository, try running in interactive mode so that you can enter your username and password');
- throw $e;
- }
- }
- $this->io->write('Authentication required ('.$this->url.'):');
- $username = $this->io->ask('Username: ');
- $password = $this->io->askAndHideAnswer('Password: ');
- $this->io->setAuthorization($this->originUrl, $username, $password);
- break;
+ $this->isPrivate = !empty($repoData['private']);
+ if (isset($repoData['default_branch'])) {
+ $this->rootIdentifier = $repoData['default_branch'];
+ } elseif (isset($repoData['master_branch'])) {
+ $this->rootIdentifier = $repoData['master_branch'];
+ } else {
+ $this->rootIdentifier = 'master';
+ }
+ $this->hasIssues = !empty($repoData['has_issues']);
+ }
- case 403:
- if (!$this->io->hasAuthorization($this->originUrl)) {
- if (!$this->io->isInteractive()) {
- $this->io->write('API limit exhausted. Failed to clone the '.$this->generateSshUrl().' repository, try running in interactive mode so that you can enter your username and password to increase the API limit');
- throw $e;
- }
- $this->io->write('API limit exhausted. Authentication required for larger API limit ('.$this->url.'):');
- $username = $this->io->ask('Username: ');
- $password = $this->io->askAndHideAnswer('Password: ');
- $this->io->setAuthorization($this->originUrl, $username, $password);
- }
- break;
+ protected function attemptCloneFallback()
+ {
+ $this->isPrivate = true;
- default:
- throw $e;
- break;
- }
- }
+ try {
+ // If this repository may be private (hard to say for sure,
+ // GitHub returns 404 for private repositories) and we
+ // cannot ask for authentication credentials (because we
+ // are not interactive) then we fallback to GitDriver.
+ $this->gitDriver = new GitDriver(
+ $this->generateSshUrl(),
+ $this->io,
+ $this->config,
+ $this->process,
+ $this->remoteFilesystem
+ );
+ $this->gitDriver->initialize();
+
+ return;
+ } catch (\RuntimeException $e) {
+ $this->gitDriver = null;
+
+ $this->io->write('Failed to clone the '.$this->generateSshUrl().' repository, try running in interactive mode so that you can enter your GitHub credentials');
+ throw $e;
}
}
+
+ protected function authorizeOAuth()
+ {
+ $attemptCounter = 0;
+
+ $this->io->write('The credentials will be swapped for an OAuth token stored in '.$this->config->get('home').'/config.json, your password will not be stored');
+ $this->io->write('To revoke access to this token you can visit https://github.com/settings/applications');
+ while ($attemptCounter++ < 5) {
+ try {
+ $username = $this->io->ask('Username: ');
+ $password = $this->io->askAndHideAnswer('Password: ');
+ $this->io->setAuthorization($this->originUrl, $username, $password);
+
+ $contents = JsonFile::parseJson($this->remoteFilesystem->getContents($this->originUrl, 'https://api.github.com/authorizations', false, array(
+ 'http' => array(
+ 'method' => 'POST',
+ 'header' => "Content-Type: application/json\r\n",
+ 'content' => '{"scopes":["repo"],"note":"Composer","note_url":"https://getcomposer.org/"}',
+ )
+ )));
+ } catch (TransportException $e) {
+ if (401 === $e->getCode()) {
+ $this->io->write('Invalid credentials.');
+ continue;
+ }
+
+ throw $e;
+ }
+
+ $this->io->setAuthorization($this->originUrl, $contents['token'], 'x-oauth-basic');
+
+ return;
+ }
+
+ throw new \RuntimeException("Invalid GitHub credentials 5 times in a row, aborting.");
+ }
}
diff --git a/src/Composer/Util/RemoteFilesystem.php b/src/Composer/Util/RemoteFilesystem.php
index 3555557ee..f7f7f93f9 100644
--- a/src/Composer/Util/RemoteFilesystem.php
+++ b/src/Composer/Util/RemoteFilesystem.php
@@ -50,12 +50,13 @@ class RemoteFilesystem
* @param string $fileUrl The file URL
* @param string $fileName the local filename
* @param boolean $progress Display the progression
+ * @param array $options Additional context options
*
* @return bool true
*/
- public function copy($originUrl, $fileUrl, $fileName, $progress = true)
+ public function copy($originUrl, $fileUrl, $fileName, $progress = true, $options = array())
{
- $this->get($originUrl, $fileUrl, $fileName, $progress);
+ $this->get($originUrl, $fileUrl, $options, $fileName, $progress);
return $this->result;
}
@@ -66,12 +67,13 @@ class RemoteFilesystem
* @param string $originUrl The origin URL
* @param string $fileUrl The file URL
* @param boolean $progress Display the progression
+ * @param array $options Additional context options
*
* @return string The content
*/
- public function getContents($originUrl, $fileUrl, $progress = true)
+ public function getContents($originUrl, $fileUrl, $progress = true, $options = array())
{
- $this->get($originUrl, $fileUrl, null, $progress);
+ $this->get($originUrl, $fileUrl, $options, null, $progress);
return $this->result;
}
@@ -79,14 +81,15 @@ class RemoteFilesystem
/**
* Get file content or copy action.
*
- * @param string $originUrl The origin URL
- * @param string $fileUrl The file URL
- * @param string $fileName the local filename
- * @param boolean $progress Display the progression
+ * @param string $originUrl The origin URL
+ * @param string $fileUrl The file URL
+ * @param array $additionalOptions context options
+ * @param string $fileName the local filename
+ * @param boolean $progress Display the progression
*
* @throws TransportException When the file could not be downloaded
*/
- protected function get($originUrl, $fileUrl, $fileName = null, $progress = true)
+ protected function get($originUrl, $fileUrl, $additionalOptions = array(), $fileName = null, $progress = true)
{
$this->bytesMax = 0;
$this->result = null;
@@ -96,7 +99,7 @@ class RemoteFilesystem
$this->progress = $progress;
$this->lastProgress = null;
- $options = $this->getOptionsForUrl($originUrl);
+ $options = $this->getOptionsForUrl($originUrl, $additionalOptions);
$ctx = StreamContextFactory::getContext($options, array('notification' => array($this, 'callbackGet')));
if ($this->progress) {
@@ -110,11 +113,20 @@ class RemoteFilesystem
}
$errorMessage .= preg_replace('{^file_get_contents\(.*?\): }', '', $msg);
});
- $result = file_get_contents($fileUrl, false, $ctx);
+ try {
+ $result = file_get_contents($fileUrl, false, $ctx);
+ } catch (\Exception $e) {
+ if ($e instanceof TransportException && !empty($http_response_header[0])) {
+ $e->setHeaders($http_response_header);
+ }
+ }
if ($errorMessage && !ini_get('allow_url_fopen')) {
$errorMessage = 'allow_url_fopen must be enabled in php.ini ('.$errorMessage.')';
}
restore_error_handler();
+ if (isset($e)) {
+ throw $e;
+ }
// fix for 5.4.0 https://bugs.php.net/bug.php?id=61336
if (!empty($http_response_header[0]) && preg_match('{^HTTP/\S+ 404}i', $http_response_header[0])) {
@@ -239,9 +251,9 @@ class RemoteFilesystem
}
}
- protected function getOptionsForUrl($originUrl)
+ protected function getOptionsForUrl($originUrl, $additionalOptions)
{
- $options['http']['header'] = sprintf(
+ $header = sprintf(
"User-Agent: Composer/%s (%s; %s; PHP %s.%s.%s)\r\n",
Composer::VERSION,
php_uname('s'),
@@ -251,16 +263,22 @@ class RemoteFilesystem
PHP_RELEASE_VERSION
);
if (extension_loaded('zlib')) {
- $options['http']['header'] .= 'Accept-Encoding: gzip'."\r\n";
+ $header .= 'Accept-Encoding: gzip'."\r\n";
}
if ($this->io->hasAuthorization($originUrl)) {
$auth = $this->io->getAuthorization($originUrl);
$authStr = base64_encode($auth['username'] . ':' . $auth['password']);
- $options['http']['header'] .= "Authorization: Basic $authStr\r\n";
+ $header .= "Authorization: Basic $authStr\r\n";
}
- $options = array_replace_recursive($options, $this->options);
+ $options = array_replace_recursive($this->options, $additionalOptions);
+
+ if (isset($options['http']['header'])) {
+ $options['http']['header'] = rtrim($options['http']['header'], "\r\n") . "\r\n" . $header;
+ } else {
+ $options['http']['header'] = $header;
+ }
return $options;
}