Migrate SFTP_Key external storage to new API
The SFTP backend now supports public key authentication alongside password authentication.
This commit is contained in:
parent
cb1ef82702
commit
1084e3adc7
|
@ -40,7 +40,6 @@ OC::$CLASSPATH['OC\Files\Storage\SMB'] = 'files_external/lib/smb.php';
|
|||
OC::$CLASSPATH['OC\Files\Storage\AmazonS3'] = 'files_external/lib/amazons3.php';
|
||||
OC::$CLASSPATH['OC\Files\Storage\Dropbox'] = 'files_external/lib/dropbox.php';
|
||||
OC::$CLASSPATH['OC\Files\Storage\SFTP'] = 'files_external/lib/sftp.php';
|
||||
OC::$CLASSPATH['OC\Files\Storage\SFTP_Key'] = 'files_external/lib/sftp_key.php';
|
||||
OC::$CLASSPATH['OC_Mount_Config'] = 'files_external/lib/config.php';
|
||||
OC::$CLASSPATH['OCA\Files\External\Api'] = 'files_external/lib/api.php';
|
||||
|
||||
|
@ -68,17 +67,5 @@ if (OCP\Config::getAppValue('files_external', 'allow_user_mounting', 'yes') == '
|
|||
// connecting hooks
|
||||
OCP\Util::connectHook('OC_Filesystem', 'post_initMountPoints', '\OC_Mount_Config', 'initMountPointsHook');
|
||||
|
||||
OC_Mount_Config::registerBackend('\OC\Files\Storage\SFTP_Key', [
|
||||
'backend' => (string)$l->t('SFTP with secret key login'),
|
||||
'priority' => 100,
|
||||
'configuration' => array(
|
||||
'host' => (string)$l->t('Host'),
|
||||
'user' => (string)$l->t('Username'),
|
||||
'public_key' => (string)$l->t('Public key'),
|
||||
'private_key' => '#private_key',
|
||||
'root' => '&'.$l->t('Remote subfolder')),
|
||||
'custom' => 'sftp_key',
|
||||
]
|
||||
);
|
||||
$mountProvider = $appContainer->query('OCA\Files_External\Config\ConfigAdapter');
|
||||
\OC::$server->getMountProviderCollection()->registerProvider($mountProvider);
|
||||
|
|
|
@ -69,6 +69,7 @@ class Application extends App {
|
|||
$container->query('OCA\Files_External\Lib\Backend\Dropbox'),
|
||||
$container->query('OCA\Files_External\Lib\Backend\Google'),
|
||||
$container->query('OCA\Files_External\Lib\Backend\Swift'),
|
||||
$container->query('OCA\Files_External\Lib\Backend\SFTP_Key'),
|
||||
]);
|
||||
|
||||
if (!\OC_Util::runningOnWindows()) {
|
||||
|
@ -103,6 +104,9 @@ class Application extends App {
|
|||
// AuthMechanism::SCHEME_OAUTH2 mechanisms
|
||||
$container->query('OCA\Files_External\Lib\Auth\OAuth2\OAuth2'),
|
||||
|
||||
// AuthMechanism::SCHEME_PUBLICKEY mechanisms
|
||||
$container->query('OCA\Files_External\Lib\Auth\PublicKey\RSA'),
|
||||
|
||||
// AuthMechanism::SCHEME_OPENSTACK mechanisms
|
||||
$container->query('OCA\Files_External\Lib\Auth\OpenStack\OpenStack'),
|
||||
$container->query('OCA\Files_External\Lib\Auth\OpenStack\Rackspace'),
|
||||
|
|
|
@ -38,7 +38,7 @@ namespace OCA\Files_External\AppInfo;
|
|||
'routes' => array(
|
||||
array(
|
||||
'name' => 'Ajax#getSshKeys',
|
||||
'url' => '/ajax/sftp_key.php',
|
||||
'url' => '/ajax/public_key.php',
|
||||
'verb' => 'POST',
|
||||
'requirements' => array()
|
||||
)
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
$(document).ready(function() {
|
||||
|
||||
OCA.External.Settings.mountConfig.whenSelectAuthMechanism(function($tr, authMechanism, scheme) {
|
||||
if (scheme === 'publickey') {
|
||||
var config = $tr.find('.configuration');
|
||||
if ($(config).find('[name="public_key_generate"]').length === 0) {
|
||||
setupTableRow($tr, config);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$('#externalStorage').on('click', '[name="public_key_generate"]', function(event) {
|
||||
event.preventDefault();
|
||||
var tr = $(this).parent().parent();
|
||||
generateKeys(tr);
|
||||
});
|
||||
|
||||
function setupTableRow(tr, config) {
|
||||
$(config).append($(document.createElement('input'))
|
||||
.addClass('button auth-param')
|
||||
.attr('type', 'button')
|
||||
.attr('value', t('files_external', 'Generate keys'))
|
||||
.attr('name', 'public_key_generate')
|
||||
);
|
||||
// If there's no private key, build one
|
||||
if (0 === $(config).find('[data-parameter="private_key"]').val().length) {
|
||||
generateKeys(tr);
|
||||
}
|
||||
}
|
||||
|
||||
function generateKeys(tr) {
|
||||
var config = $(tr).find('.configuration');
|
||||
|
||||
$.post(OC.filePath('files_external', 'ajax', 'public_key.php'), {}, function(result) {
|
||||
if (result && result.status === 'success') {
|
||||
$(config).find('[data-parameter="public_key"]').val(result.data.public_key);
|
||||
$(config).find('[data-parameter="private_key"]').val(result.data.private_key);
|
||||
OCA.External.Settings.mountConfig.saveStorageConfig(tr, function() {
|
||||
// Nothing to do
|
||||
});
|
||||
} else {
|
||||
OC.dialogs.alert(result.data.message, t('files_external', 'Error generating key pair') );
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
|
@ -1,53 +0,0 @@
|
|||
$(document).ready(function() {
|
||||
|
||||
$('#externalStorage tbody tr.\\\\OC\\\\Files\\\\Storage\\\\SFTP_Key').each(function() {
|
||||
var tr = $(this);
|
||||
var config = $(tr).find('.configuration');
|
||||
if ($(config).find('.sftp_key').length === 0) {
|
||||
setupTableRow(tr, config);
|
||||
}
|
||||
});
|
||||
|
||||
// We can't catch the DOM elements being added, but we can pick up when
|
||||
// they receive focus
|
||||
$('#externalStorage').on('focus', 'tbody tr.\\\\OC\\\\Files\\\\Storage\\\\SFTP_Key', function() {
|
||||
var tr = $(this);
|
||||
var config = $(tr).find('.configuration');
|
||||
|
||||
if ($(config).find('.sftp_key').length === 0) {
|
||||
setupTableRow(tr, config);
|
||||
}
|
||||
});
|
||||
|
||||
$('#externalStorage').on('click', '.sftp_key', function(event) {
|
||||
event.preventDefault();
|
||||
var tr = $(this).parent().parent();
|
||||
generateKeys(tr);
|
||||
});
|
||||
|
||||
function setupTableRow(tr, config) {
|
||||
$(config).append($(document.createElement('input')).addClass('button sftp_key')
|
||||
.attr('type', 'button')
|
||||
.attr('value', t('files_external', 'Generate keys')));
|
||||
// If there's no private key, build one
|
||||
if (0 === $(config).find('[data-parameter="private_key"]').val().length) {
|
||||
generateKeys(tr);
|
||||
}
|
||||
}
|
||||
|
||||
function generateKeys(tr) {
|
||||
var config = $(tr).find('.configuration');
|
||||
|
||||
$.post(OC.filePath('files_external', 'ajax', 'sftp_key.php'), {}, function(result) {
|
||||
if (result && result.status === 'success') {
|
||||
$(config).find('[data-parameter="public_key"]').val(result.data.public_key);
|
||||
$(config).find('[data-parameter="private_key"]').val(result.data.private_key);
|
||||
OCA.External.mountConfig.saveStorageConfig(tr, function() {
|
||||
// Nothing to do
|
||||
});
|
||||
} else {
|
||||
OC.dialogs.alert(result.data.message, t('files_external', 'Error generating key pair') );
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
|
@ -0,0 +1,65 @@
|
|||
<?php
|
||||
/**
|
||||
* @author Robin McCorkell <rmccorkell@owncloud.com>
|
||||
*
|
||||
* @copyright Copyright (c) 2015, ownCloud, Inc.
|
||||
* @license AGPL-3.0
|
||||
*
|
||||
* This code is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, version 3,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License, version 3,
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
*
|
||||
*/
|
||||
|
||||
namespace OCA\Files_External\Lib\Auth\PublicKey;
|
||||
|
||||
use \OCP\IL10N;
|
||||
use \OCA\Files_External\Lib\DefinitionParameter;
|
||||
use \OCA\Files_External\Lib\Auth\AuthMechanism;
|
||||
use \OCA\Files_External\Lib\StorageConfig;
|
||||
use \OCP\IConfig;
|
||||
use \phpseclib\Crypt\RSA as RSACrypt;
|
||||
|
||||
/**
|
||||
* RSA public key authentication
|
||||
*/
|
||||
class RSA extends AuthMechanism {
|
||||
|
||||
/** @var IConfig */
|
||||
private $config;
|
||||
|
||||
public function __construct(IL10N $l, IConfig $config) {
|
||||
$this->config = $config;
|
||||
|
||||
$this
|
||||
->setIdentifier('publickey::rsa')
|
||||
->setScheme(self::SCHEME_PUBLICKEY)
|
||||
->setText($l->t('RSA public key'))
|
||||
->addParameters([
|
||||
(new DefinitionParameter('user', $l->t('Username'))),
|
||||
(new DefinitionParameter('public_key', $l->t('Public key'))),
|
||||
(new DefinitionParameter('private_key', 'private_key'))
|
||||
->setType(DefinitionParameter::VALUE_HIDDEN),
|
||||
])
|
||||
->setCustomJs('public_key')
|
||||
;
|
||||
}
|
||||
|
||||
public function manipulateStorageConfig(StorageConfig &$storage) {
|
||||
$auth = new RSACrypt();
|
||||
$auth->setPassword($this->config->getSystemValue('secret', ''));
|
||||
if (!$auth->loadKey($storage->getBackendOption('private_key'))) {
|
||||
throw new \RuntimeException('unable to load private key');
|
||||
}
|
||||
$storage->setBackendOption('public_key_auth', $auth);
|
||||
}
|
||||
|
||||
}
|
|
@ -43,6 +43,7 @@ class SFTP extends Backend {
|
|||
->setFlag(DefinitionParameter::FLAG_OPTIONAL),
|
||||
])
|
||||
->addAuthScheme(AuthMechanism::SCHEME_PASSWORD)
|
||||
->addAuthScheme(AuthMechanism::SCHEME_PUBLICKEY)
|
||||
->setLegacyAuthMechanism($legacyAuth)
|
||||
;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
/**
|
||||
* @author Robin McCorkell <rmccorkell@owncloud.com>
|
||||
*
|
||||
* @copyright Copyright (c) 2015, ownCloud, Inc.
|
||||
* @license AGPL-3.0
|
||||
*
|
||||
* This code is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, version 3,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License, version 3,
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
*
|
||||
*/
|
||||
|
||||
namespace OCA\Files_External\Lib\Backend;
|
||||
|
||||
use \OCP\IL10N;
|
||||
use \OCA\Files_External\Lib\Backend\Backend;
|
||||
use \OCA\Files_External\Lib\DefinitionParameter;
|
||||
use \OCA\Files_External\Lib\Auth\AuthMechanism;
|
||||
use \OCA\Files_External\Service\BackendService;
|
||||
use \OCA\Files_External\Lib\Auth\PublicKey\RSA;
|
||||
|
||||
class SFTP_Key extends Backend {
|
||||
|
||||
public function __construct(IL10N $l, RSA $legacyAuth) {
|
||||
$this
|
||||
->setIdentifier('\OC\Files\Storage\SFTP_Key')
|
||||
->setStorageClass('\OC\Files\Storage\SFTP')
|
||||
->setText($l->t('SFTP with secret key login [DEPRECATED]'))
|
||||
->addParameters([
|
||||
(new DefinitionParameter('host', $l->t('Host'))),
|
||||
(new DefinitionParameter('root', $l->t('Remote subfolder')))
|
||||
->setFlag(DefinitionParameter::FLAG_OPTIONAL),
|
||||
])
|
||||
->addAuthScheme(AuthMechanism::SCHEME_PUBLICKEY)
|
||||
->setLegacyAuthMechanism($legacyAuth)
|
||||
;
|
||||
}
|
||||
|
||||
}
|
|
@ -40,10 +40,11 @@ use phpseclib\Net\SFTP\Stream;
|
|||
class SFTP extends \OC\Files\Storage\Common {
|
||||
private $host;
|
||||
private $user;
|
||||
private $password;
|
||||
private $root;
|
||||
private $port = 22;
|
||||
|
||||
private $auth;
|
||||
|
||||
/**
|
||||
* @var SFTP
|
||||
*/
|
||||
|
@ -73,8 +74,15 @@ class SFTP extends \OC\Files\Storage\Common {
|
|||
}
|
||||
|
||||
$this->user = $params['user'];
|
||||
$this->password
|
||||
= isset($params['password']) ? $params['password'] : '';
|
||||
|
||||
if (isset($params['public_key_auth'])) {
|
||||
$this->auth = $params['public_key_auth'];
|
||||
} elseif (isset($params['password'])) {
|
||||
$this->auth = $params['password'];
|
||||
} else {
|
||||
throw new \UnexpectedValueException('no authentication parameters specified');
|
||||
}
|
||||
|
||||
$this->root
|
||||
= isset($params['root']) ? $this->cleanPath($params['root']) : '/';
|
||||
|
||||
|
@ -112,7 +120,7 @@ class SFTP extends \OC\Files\Storage\Common {
|
|||
$this->writeHostKeys($hostKeys);
|
||||
}
|
||||
|
||||
if (!$this->client->login($this->user, $this->password)) {
|
||||
if (!$this->client->login($this->user, $this->auth)) {
|
||||
throw new \Exception('Login failed');
|
||||
}
|
||||
return $this->client;
|
||||
|
@ -125,7 +133,6 @@ class SFTP extends \OC\Files\Storage\Common {
|
|||
if (
|
||||
!isset($this->host)
|
||||
|| !isset($this->user)
|
||||
|| !isset($this->password)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
|
|
@ -1,215 +0,0 @@
|
|||
<?php
|
||||
/**
|
||||
* @author Lukas Reschke <lukas@owncloud.com>
|
||||
* @author Morris Jobke <hey@morrisjobke.de>
|
||||
* @author Ross Nicoll <jrn@jrn.me.uk>
|
||||
*
|
||||
* @copyright Copyright (c) 2015, ownCloud, Inc.
|
||||
* @license AGPL-3.0
|
||||
*
|
||||
* This code is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, version 3,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License, version 3,
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
*
|
||||
*/
|
||||
namespace OC\Files\Storage;
|
||||
|
||||
use phpseclib\Crypt\RSA;
|
||||
|
||||
class SFTP_Key extends \OC\Files\Storage\SFTP {
|
||||
private $publicKey;
|
||||
private $privateKey;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function __construct($params) {
|
||||
parent::__construct($params);
|
||||
$this->publicKey = $params['public_key'];
|
||||
$this->privateKey = $params['private_key'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the connection.
|
||||
*
|
||||
* @return \phpseclib\Net\SFTP connected client instance
|
||||
* @throws \Exception when the connection failed
|
||||
*/
|
||||
public function getConnection() {
|
||||
if (!is_null($this->client)) {
|
||||
return $this->client;
|
||||
}
|
||||
|
||||
$hostKeys = $this->readHostKeys();
|
||||
$this->client = new \phpseclib\Net\SFTP($this->getHost());
|
||||
|
||||
// The SSH Host Key MUST be verified before login().
|
||||
$currentHostKey = $this->client->getServerPublicHostKey();
|
||||
if (array_key_exists($this->getHost(), $hostKeys)) {
|
||||
if ($hostKeys[$this->getHost()] !== $currentHostKey) {
|
||||
throw new \Exception('Host public key does not match known key');
|
||||
}
|
||||
} else {
|
||||
$hostKeys[$this->getHost()] = $currentHostKey;
|
||||
$this->writeHostKeys($hostKeys);
|
||||
}
|
||||
|
||||
$key = $this->getPrivateKey();
|
||||
if (is_null($key)) {
|
||||
throw new \Exception('Secret key could not be loaded');
|
||||
}
|
||||
if (!$this->client->login($this->getUser(), $key)) {
|
||||
throw new \Exception('Login failed');
|
||||
}
|
||||
return $this->client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the private key to be used for authentication to the remote server.
|
||||
*
|
||||
* @return RSA instance or null in case of a failure to load the key.
|
||||
*/
|
||||
private function getPrivateKey() {
|
||||
$key = new RSA();
|
||||
$key->setPassword(\OC::$server->getConfig()->getSystemValue('secret', ''));
|
||||
if (!$key->loadKey($this->privateKey)) {
|
||||
// Should this exception rather than return null?
|
||||
return null;
|
||||
}
|
||||
return $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws an exception if the provided host name/address is invalid (cannot be resolved
|
||||
* and is not an IPv4 address).
|
||||
*
|
||||
* @return true; never returns in case of a problem, this return value is used just to
|
||||
* make unit tests happy.
|
||||
*/
|
||||
public function assertHostAddressValid($hostname) {
|
||||
// TODO: Should handle IPv6 addresses too
|
||||
if (!preg_match('/^\d+\.\d+\.\d+\.\d+$/', $hostname) && gethostbyname($hostname) === $hostname) {
|
||||
// Hostname is not an IPv4 address and cannot be resolved via DNS
|
||||
throw new \InvalidArgumentException('Cannot resolve hostname.');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws an exception if the provided port number is invalid (cannot be resolved
|
||||
* and is not an IPv4 address).
|
||||
*
|
||||
* @return true; never returns in case of a problem, this return value is used just to
|
||||
* make unit tests happy.
|
||||
*/
|
||||
public function assertPortNumberValid($port) {
|
||||
if (!preg_match('/^\d+$/', $port)) {
|
||||
throw new \InvalidArgumentException('Port number must be a number.');
|
||||
}
|
||||
if ($port < 0 || $port > 65535) {
|
||||
throw new \InvalidArgumentException('Port number must be between 0 and 65535 inclusive.');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces anything that's not an alphanumeric character or "." in a hostname
|
||||
* with "_", to make it safe for use as part of a file name.
|
||||
*/
|
||||
protected function sanitizeHostName($name) {
|
||||
return preg_replace('/[^\d\w\._]/', '_', $name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces anything that's not an alphanumeric character or "_" in a username
|
||||
* with "_", to make it safe for use as part of a file name.
|
||||
*/
|
||||
protected function sanitizeUserName($name) {
|
||||
return preg_replace('/[^\d\w_]/', '_', $name);
|
||||
}
|
||||
|
||||
public function test() {
|
||||
|
||||
// FIXME: Use as expression in empty once PHP 5.4 support is dropped
|
||||
$host = $this->getHost();
|
||||
if (empty($host)) {
|
||||
\OC::$server->getLogger()->warning('Hostname has not been specified');
|
||||
return false;
|
||||
}
|
||||
// FIXME: Use as expression in empty once PHP 5.4 support is dropped
|
||||
$user = $this->getUser();
|
||||
if (empty($user)) {
|
||||
\OC::$server->getLogger()->warning('Username has not been specified');
|
||||
return false;
|
||||
}
|
||||
if (!isset($this->privateKey)) {
|
||||
\OC::$server->getLogger()->warning('Private key was missing from the request');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Sanity check the host
|
||||
$hostParts = explode(':', $this->getHost());
|
||||
try {
|
||||
if (count($hostParts) == 1) {
|
||||
$hostname = $hostParts[0];
|
||||
$this->assertHostAddressValid($hostname);
|
||||
} else if (count($hostParts) == 2) {
|
||||
$hostname = $hostParts[0];
|
||||
$this->assertHostAddressValid($hostname);
|
||||
$this->assertPortNumberValid($hostParts[1]);
|
||||
} else {
|
||||
throw new \Exception('Host connection string is invalid.');
|
||||
}
|
||||
} catch(\Exception $e) {
|
||||
\OC::$server->getLogger()->warning($e->getMessage());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate the key
|
||||
$key = $this->getPrivateKey();
|
||||
if (is_null($key)) {
|
||||
\OC::$server->getLogger()->warning('Secret key could not be loaded');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
if ($this->getConnection()->nlist() === false) {
|
||||
return false;
|
||||
}
|
||||
} catch(\Exception $e) {
|
||||
// We should be throwing a more specific error, so we're not just catching
|
||||
// Exception here
|
||||
\OC::$server->getLogger()->warning($e->getMessage());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Save the key somewhere it can easily be extracted later
|
||||
if (\OC::$server->getUserSession()->getUser()) {
|
||||
$view = new \OC\Files\View('/'.\OC::$server->getUserSession()->getUser()->getUId().'/files_external/sftp_keys');
|
||||
if (!$view->is_dir('')) {
|
||||
if (!$view->mkdir('')) {
|
||||
\OC::$server->getLogger()->warning('Could not create secret key directory.');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
$key_filename = $this->sanitizeUserName($this->getUser()).'@'.$this->sanitizeHostName($hostname).'.pub';
|
||||
$key_file = $view->fopen($key_filename, "w");
|
||||
if ($key_file) {
|
||||
fwrite($key_file, $this->publicKey);
|
||||
fclose($key_file);
|
||||
} else {
|
||||
\OC::$server->getLogger()->warning('Could not write secret key file.');
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue