Merge remote-tracking branch 'wenkepaul/master'
commit
2c9326bacb
|
@ -778,7 +778,7 @@ file to be used during SSL/TLS peer verification.
|
||||||
|
|
||||||
The `COMPOSER_AUTH` var allows you to set up authentication as an environment variable.
|
The `COMPOSER_AUTH` var allows you to set up authentication as an environment variable.
|
||||||
The contents of the variable should be a JSON formatted object containing http-basic,
|
The contents of the variable should be a JSON formatted object containing http-basic,
|
||||||
github-oauth, ... objects as needed, and following the
|
github-oauth, bitbucket-oauth, ... objects as needed, and following the
|
||||||
[spec from the config](06-config.md#gitlab-oauth).
|
[spec from the config](06-config.md#gitlab-oauth).
|
||||||
|
|
||||||
### COMPOSER_DISCARD_CHANGES
|
### COMPOSER_DISCARD_CHANGES
|
||||||
|
|
|
@ -81,6 +81,12 @@ downloaded via Composer. If you really absolutely need HTTP access to something
|
||||||
then you can disable it, but using [Let's Encrypt](https://letsencrypt.org/) to
|
then you can disable it, but using [Let's Encrypt](https://letsencrypt.org/) to
|
||||||
get a free SSL certificate is generally a better alternative.
|
get a free SSL certificate is generally a better alternative.
|
||||||
|
|
||||||
|
## bitbucket-oauth
|
||||||
|
|
||||||
|
A list of domain names and consumers. For example using `{"bitbucket.org":
|
||||||
|
{"consumer-key": "myKey", "consumer-secret": "mySecret"}}`. [Read](https://confluence.atlassian.com/bitbucket/oauth-on-bitbucket-cloud-238027431.html)
|
||||||
|
how to set up a consumer on Bitbucket.
|
||||||
|
|
||||||
## cafile
|
## cafile
|
||||||
|
|
||||||
Location of Certificate Authority file on local filesystem. In PHP 5.6+ you
|
Location of Certificate Authority file on local filesystem. In PHP 5.6+ you
|
||||||
|
|
|
@ -491,6 +491,17 @@ EOT
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handle bitbucket-oauth
|
||||||
|
if (preg_match('/^(bitbucket-oauth)\.(.+)/', $settingKey, $matches)) {
|
||||||
|
if (2 !== count($values)) {
|
||||||
|
throw new \RuntimeException('Expected two arguments (consumer-key, consumer-secret), got '.count($values));
|
||||||
|
}
|
||||||
|
$this->configSource->removeConfigSetting($matches[1].'.'.$matches[2]);
|
||||||
|
$this->authConfigSource->addConfigSetting($matches[1].'.'.$matches[2], array('consumer-key' => $values[0], 'consumer-secret' => $values[1]));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
throw new \InvalidArgumentException('Setting '.$settingKey.' does not exist or is not supported by this command');
|
throw new \InvalidArgumentException('Setting '.$settingKey.' does not exist or is not supported by this command');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -46,6 +46,7 @@ class Config
|
||||||
'classmap-authoritative' => false,
|
'classmap-authoritative' => false,
|
||||||
'prepend-autoloader' => true,
|
'prepend-autoloader' => true,
|
||||||
'github-domains' => array('github.com'),
|
'github-domains' => array('github.com'),
|
||||||
|
'bitbucket-expose-hostname' => true,
|
||||||
'disable-tls' => false,
|
'disable-tls' => false,
|
||||||
'secure-http' => true,
|
'secure-http' => true,
|
||||||
'cafile' => null,
|
'cafile' => null,
|
||||||
|
@ -60,6 +61,7 @@ class Config
|
||||||
// github-oauth
|
// github-oauth
|
||||||
// gitlab-oauth
|
// gitlab-oauth
|
||||||
// http-basic
|
// http-basic
|
||||||
|
// bitbucket-oauth
|
||||||
);
|
);
|
||||||
|
|
||||||
public static $defaultRepositories = array(
|
public static $defaultRepositories = array(
|
||||||
|
|
|
@ -81,7 +81,7 @@ class JsonConfigSource implements ConfigSourceInterface
|
||||||
{
|
{
|
||||||
$authConfig = $this->authConfig;
|
$authConfig = $this->authConfig;
|
||||||
$this->manipulateJson('addConfigSetting', $name, $value, function (&$config, $key, $val) use ($authConfig) {
|
$this->manipulateJson('addConfigSetting', $name, $value, function (&$config, $key, $val) use ($authConfig) {
|
||||||
if (preg_match('{^(github-oauth|gitlab-oauth|http-basic|platform)\.}', $key)) {
|
if (preg_match('{^(github-oauth|gitlab-oauth|http-basic|platform|bitbucket-oauth)\.}', $key)) {
|
||||||
list($key, $host) = explode('.', $key, 2);
|
list($key, $host) = explode('.', $key, 2);
|
||||||
if ($authConfig) {
|
if ($authConfig) {
|
||||||
$config[$key][$host] = $val;
|
$config[$key][$host] = $val;
|
||||||
|
|
|
@ -88,6 +88,7 @@ abstract class BaseIO implements IOInterface
|
||||||
$githubOauth = $config->get('github-oauth') ?: array();
|
$githubOauth = $config->get('github-oauth') ?: array();
|
||||||
$gitlabOauth = $config->get('gitlab-oauth') ?: array();
|
$gitlabOauth = $config->get('gitlab-oauth') ?: array();
|
||||||
$httpBasic = $config->get('http-basic') ?: array();
|
$httpBasic = $config->get('http-basic') ?: array();
|
||||||
|
$bitbucketOauth = $config->get('bitbucket-oauth') ?: array();
|
||||||
|
|
||||||
// reload oauth token from config if available
|
// reload oauth token from config if available
|
||||||
foreach ($githubOauth as $domain => $token) {
|
foreach ($githubOauth as $domain => $token) {
|
||||||
|
@ -106,6 +107,10 @@ abstract class BaseIO implements IOInterface
|
||||||
$this->checkAndSetAuthentication($domain, $cred['username'], $cred['password']);
|
$this->checkAndSetAuthentication($domain, $cred['username'], $cred['password']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach ($bitbucketOauth as $domain => $cred) {
|
||||||
|
$this->checkAndSetAuthentication($domain, $cred['consumer-key'], $cred['consumer-secret']);
|
||||||
|
}
|
||||||
|
|
||||||
// setup process timeout
|
// setup process timeout
|
||||||
ProcessExecutor::setTimeout((int) $config->get('process-timeout'));
|
ProcessExecutor::setTimeout((int) $config->get('process-timeout'));
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,183 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of Composer.
|
||||||
|
*
|
||||||
|
* (c) Nils Adermann <naderman@naderman.de>
|
||||||
|
* Jordi Boggiano <j.boggiano@seld.be>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Composer\Util;
|
||||||
|
|
||||||
|
use Composer\Factory;
|
||||||
|
use Composer\IO\IOInterface;
|
||||||
|
use Composer\Config;
|
||||||
|
use Composer\Downloader\TransportException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Paul Wenke <wenke.paul@gmail.com>
|
||||||
|
*/
|
||||||
|
class Bitbucket
|
||||||
|
{
|
||||||
|
private $io;
|
||||||
|
private $config;
|
||||||
|
private $process;
|
||||||
|
private $remoteFilesystem;
|
||||||
|
private $token = array();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor.
|
||||||
|
*
|
||||||
|
* @param IOInterface $io The IO instance
|
||||||
|
* @param Config $config The composer configuration
|
||||||
|
* @param ProcessExecutor $process Process instance, injectable for mocking
|
||||||
|
* @param RemoteFilesystem $remoteFilesystem Remote Filesystem, injectable for mocking
|
||||||
|
*/
|
||||||
|
public function __construct(IOInterface $io, Config $config, ProcessExecutor $process = null, RemoteFilesystem $remoteFilesystem = null)
|
||||||
|
{
|
||||||
|
$this->io = $io;
|
||||||
|
$this->config = $config;
|
||||||
|
$this->process = $process ?: new ProcessExecutor;
|
||||||
|
$this->remoteFilesystem = $remoteFilesystem ?: Factory::createRemoteFilesystem($this->io, $config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function getToken()
|
||||||
|
{
|
||||||
|
return $this->token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to authorize a Bitbucket domain via OAuth
|
||||||
|
*
|
||||||
|
* @param string $originUrl The host this Bitbucket instance is located at
|
||||||
|
* @return bool true on success
|
||||||
|
*/
|
||||||
|
public function authorizeOAuth($originUrl)
|
||||||
|
{
|
||||||
|
if ($originUrl !== 'bitbucket.org') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if available use token from git config
|
||||||
|
if (0 === $this->process->execute('git config bitbucket.accesstoken', $output)) {
|
||||||
|
$this->io->setAuthentication($originUrl, 'x-token-auth', trim($output));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $originUrl
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
private function requestAccessToken($originUrl)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$apiUrl = 'https://bitbucket.org/site/oauth2/access_token';
|
||||||
|
|
||||||
|
$json = $this->remoteFilesystem->getContents($originUrl, $apiUrl, false, array(
|
||||||
|
'retry-auth-failure' => false,
|
||||||
|
'http' => array(
|
||||||
|
'method' => 'POST',
|
||||||
|
'content' => array(
|
||||||
|
'grant_type' => 'client_credentials'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
));
|
||||||
|
|
||||||
|
$this->token = json_decode($json, true);
|
||||||
|
} catch (TransportException $e) {
|
||||||
|
if (in_array($e->getCode(), array(403, 401))) {
|
||||||
|
$this->io->writeError('<error>Invalid consumer provided.</error>');
|
||||||
|
$this->io->writeError('You can also add it manually later by using "composer config bitbucket-oauth.bitbucket.org <consumer-key> <consumer-secret>"');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authorizes a Bitbucket domain interactively via OAuth
|
||||||
|
*
|
||||||
|
* @param string $originUrl The host this Bitbucket instance is located at
|
||||||
|
* @param string $message The reason this authorization is required
|
||||||
|
* @throws \RuntimeException
|
||||||
|
* @throws TransportException|\Exception
|
||||||
|
* @return bool true on success
|
||||||
|
*/
|
||||||
|
public function authorizeOAuthInteractively($originUrl, $message = null)
|
||||||
|
{
|
||||||
|
if ($message) {
|
||||||
|
$this->io->writeError($message);
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = 'https://confluence.atlassian.com/bitbucket/oauth-on-bitbucket-cloud-238027431.html';
|
||||||
|
$this->io->writeError(sprintf('Follow the instructions on %s', $url));
|
||||||
|
$this->io->writeError(sprintf('to create a consumer. It will be stored in "%s" for future use by Composer.', $this->config->getAuthConfigSource()->getName()));
|
||||||
|
|
||||||
|
$consumerKey = trim($this->io->askAndHideAnswer('Consumer Key (hidden): '));
|
||||||
|
|
||||||
|
if (!$consumerKey) {
|
||||||
|
$this->io->writeError('<warning>No consumer key given, aborting.</warning>');
|
||||||
|
$this->io->writeError('You can also add it manually later by using "composer config bitbucket-oauth.bitbucket.org <consumer-key> <consumer-secret>"');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$consumerSecret = trim($this->io->askAndHideAnswer('Consumer Secret (hidden): '));
|
||||||
|
|
||||||
|
if (!$consumerSecret) {
|
||||||
|
$this->io->writeError('<warning>No consumer secret given, aborting.</warning>');
|
||||||
|
$this->io->writeError('You can also add it manually later by using "composer config bitbucket-oauth.bitbucket.org <consumer-key> <consumer-secret>"');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->io->setAuthentication($originUrl, $consumerKey, $consumerSecret);
|
||||||
|
|
||||||
|
$this->requestAccessToken($originUrl);
|
||||||
|
|
||||||
|
// store value in user config
|
||||||
|
$this->config->getConfigSource()->removeConfigSetting('bitbucket-oauth.'.$originUrl);
|
||||||
|
|
||||||
|
$consumer = array(
|
||||||
|
"consumer-key" => $consumerKey,
|
||||||
|
"consumer-secret" => $consumerSecret
|
||||||
|
);
|
||||||
|
$this->config->getAuthConfigSource()->addConfigSetting('bitbucket-oauth.'.$originUrl, $consumer);
|
||||||
|
|
||||||
|
$this->io->writeError('<info>Consumer stored successfully.</info>');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves an access token from Bitbucket.
|
||||||
|
*
|
||||||
|
* @param string $originUrl
|
||||||
|
* @param string $consumerKey
|
||||||
|
* @param string $consumerSecret
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function requestToken($originUrl, $consumerKey, $consumerSecret)
|
||||||
|
{
|
||||||
|
if (!empty($this->token)) {
|
||||||
|
return $this->token;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->io->setAuthentication($originUrl, $consumerKey, $consumerSecret);
|
||||||
|
$this->requestAccessToken($originUrl);
|
||||||
|
|
||||||
|
return $this->token;
|
||||||
|
}
|
||||||
|
}
|
|
@ -107,7 +107,37 @@ class Git
|
||||||
|
|
||||||
if ($this->io->hasAuthentication($match[1])) {
|
if ($this->io->hasAuthentication($match[1])) {
|
||||||
$auth = $this->io->getAuthentication($match[1]);
|
$auth = $this->io->getAuthentication($match[1]);
|
||||||
$authUrl = 'https://'.rawurlencode($auth['username']) . ':' . rawurlencode($auth['password']) . '@'.$match[1].'/'.$match[2].'.git';
|
$authUrl = 'https://' . rawurlencode($auth['username']) . ':' . rawurlencode($auth['password']) . '@' . $match[1] . '/' . $match[2] . '.git';
|
||||||
|
$command = call_user_func($commandCallable, $authUrl);
|
||||||
|
if (0 === $this->process->execute($command, $ignoredOutput, $cwd)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} elseif (preg_match('{^https://(bitbucket.org)/(.*)}', $url, $match)) { //bitbucket oauth
|
||||||
|
$bitbucketUtil = new Bitbucket($this->io, $this->config, $this->process);
|
||||||
|
|
||||||
|
if (!$this->io->hasAuthentication($match[1])) {
|
||||||
|
$message = 'Enter your Bitbucket credentials to access private repos';
|
||||||
|
|
||||||
|
if (!$bitbucketUtil->authorizeOAuth($match[1]) && $this->io->isInteractive()) {
|
||||||
|
$bitbucketUtil->authorizeOAuthInteractively($match[1], $message);
|
||||||
|
$token = $bitbucketUtil->getToken();
|
||||||
|
$this->io->setAuthentication($match[1], 'x-token-auth', $token['access_token']);
|
||||||
|
}
|
||||||
|
} else { //We're authenticating with a locally stored consumer.
|
||||||
|
$auth = $this->io->getAuthentication($match[1]);
|
||||||
|
|
||||||
|
//We already have an access_token from a previous request.
|
||||||
|
if($auth['username'] !== 'x-token-auth') {
|
||||||
|
$token = $bitbucketUtil->requestToken($match[1], $auth['username'], $auth['password']);
|
||||||
|
$this->io->setAuthentication($match[1], 'x-token-auth', $token['access_token']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->io->hasAuthentication($match[1])) {
|
||||||
|
$auth = $this->io->getAuthentication($match[1]);
|
||||||
|
$authUrl = 'https://' . rawurlencode($auth['username']) . ':' . rawurlencode($auth['password']) . '@' . $match[1] . '/' . $match[2] . '.git';
|
||||||
|
|
||||||
$command = call_user_func($commandCallable, $authUrl);
|
$command = call_user_func($commandCallable, $authUrl);
|
||||||
if (0 === $this->process->execute($command, $ignoredOutput, $cwd)) {
|
if (0 === $this->process->execute($command, $ignoredOutput, $cwd)) {
|
||||||
return;
|
return;
|
||||||
|
|
|
@ -129,6 +129,10 @@ final class StreamContextFactory
|
||||||
$options['http']['header'] = self::fixHttpHeaderField($options['http']['header']);
|
$options['http']['header'] = self::fixHttpHeaderField($options['http']['header']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isset($options['http']['content']) && (is_array($options['http']['content']) || is_object($options['http']['content']))) {
|
||||||
|
$options['http']['content'] = http_build_query($options['http']['content']);
|
||||||
|
}
|
||||||
|
|
||||||
if (defined('HHVM_VERSION')) {
|
if (defined('HHVM_VERSION')) {
|
||||||
$phpVersion = 'HHVM ' . HHVM_VERSION;
|
$phpVersion = 'HHVM ' . HHVM_VERSION;
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -0,0 +1,136 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of Composer.
|
||||||
|
*
|
||||||
|
* (c) Nils Adermann <naderman@naderman.de>
|
||||||
|
* Jordi Boggiano <j.boggiano@seld.be>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Composer\Test\Util;
|
||||||
|
|
||||||
|
use Composer\Util\Bitbucket;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Paul Wenke <wenke.paul@gmail.com>
|
||||||
|
*/
|
||||||
|
class BitbucketTest extends \PHPUnit_Framework_TestCase
|
||||||
|
{
|
||||||
|
private $username = 'username';
|
||||||
|
private $password = 'password';
|
||||||
|
private $authcode = 'authcode';
|
||||||
|
private $message = 'mymessage';
|
||||||
|
private $origin = 'bitbucket.org';
|
||||||
|
private $token = 'bitbuckettoken';
|
||||||
|
|
||||||
|
public function testUsernamePasswordAuthenticationFlow()
|
||||||
|
{
|
||||||
|
$io = $this->getIOMock();
|
||||||
|
$io
|
||||||
|
->expects($this->at(0))
|
||||||
|
->method('writeError')
|
||||||
|
->with($this->message)
|
||||||
|
;
|
||||||
|
|
||||||
|
$io->expects($this->exactly(2))
|
||||||
|
->method('askAndHideAnswer')
|
||||||
|
->withConsecutive(
|
||||||
|
array('Consumer Key (hidden): '),
|
||||||
|
array('Consumer Secret (hidden): ')
|
||||||
|
)
|
||||||
|
->willReturnOnConsecutiveCalls($this->username, $this->password);
|
||||||
|
|
||||||
|
$rfs = $this->getRemoteFilesystemMock();
|
||||||
|
$rfs
|
||||||
|
->expects($this->once())
|
||||||
|
->method('getContents')
|
||||||
|
->with(
|
||||||
|
$this->equalTo($this->origin),
|
||||||
|
$this->equalTo(sprintf('https://%s/site/oauth2/access_token', $this->origin)),
|
||||||
|
$this->isFalse(),
|
||||||
|
$this->anything()
|
||||||
|
)
|
||||||
|
->willReturn(sprintf('{}', $this->token))
|
||||||
|
;
|
||||||
|
|
||||||
|
$config = $this->getConfigMock();
|
||||||
|
$config
|
||||||
|
->expects($this->exactly(2))
|
||||||
|
->method('getAuthConfigSource')
|
||||||
|
->willReturn($this->getAuthJsonMock())
|
||||||
|
;
|
||||||
|
$config
|
||||||
|
->expects($this->once())
|
||||||
|
->method('getConfigSource')
|
||||||
|
->willReturn($this->getConfJsonMock())
|
||||||
|
;
|
||||||
|
|
||||||
|
$bitbucket = new Bitbucket($io, $config, null, $rfs);
|
||||||
|
|
||||||
|
$this->assertTrue($bitbucket->authorizeOAuthInteractively($this->origin, $this->message));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getIOMock()
|
||||||
|
{
|
||||||
|
$io = $this
|
||||||
|
->getMockBuilder('Composer\IO\ConsoleIO')
|
||||||
|
->disableOriginalConstructor()
|
||||||
|
->getMock()
|
||||||
|
;
|
||||||
|
|
||||||
|
return $io;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getConfigMock()
|
||||||
|
{
|
||||||
|
$config = $this->getMock('Composer\Config');
|
||||||
|
|
||||||
|
return $config;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getRemoteFilesystemMock()
|
||||||
|
{
|
||||||
|
$rfs = $this
|
||||||
|
->getMockBuilder('Composer\Util\RemoteFilesystem')
|
||||||
|
->disableOriginalConstructor()
|
||||||
|
->getMock()
|
||||||
|
;
|
||||||
|
|
||||||
|
return $rfs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getAuthJsonMock()
|
||||||
|
{
|
||||||
|
$authjson = $this
|
||||||
|
->getMockBuilder('Composer\Config\JsonConfigSource')
|
||||||
|
->disableOriginalConstructor()
|
||||||
|
->getMock()
|
||||||
|
;
|
||||||
|
$authjson
|
||||||
|
->expects($this->atLeastOnce())
|
||||||
|
->method('getName')
|
||||||
|
->willReturn('auth.json')
|
||||||
|
;
|
||||||
|
|
||||||
|
return $authjson;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getConfJsonMock()
|
||||||
|
{
|
||||||
|
$confjson = $this
|
||||||
|
->getMockBuilder('Composer\Config\JsonConfigSource')
|
||||||
|
->disableOriginalConstructor()
|
||||||
|
->getMock()
|
||||||
|
;
|
||||||
|
$confjson
|
||||||
|
->expects($this->atLeastOnce())
|
||||||
|
->method('removeConfigSetting')
|
||||||
|
->with('bitbucket-oauth.'.$this->origin)
|
||||||
|
;
|
||||||
|
|
||||||
|
return $confjson;
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue