chg: [requestProcessors] Usage of connector name, connector/user/broods validations & UI improvements
parent
048842263a
commit
974ed02e9b
|
@ -1,6 +1,7 @@
|
|||
<?php
|
||||
use Cake\ORM\TableRegistry;
|
||||
use Cake\Filesystem\File;
|
||||
use Cake\Http\Exception\NotFoundException;
|
||||
|
||||
require_once(ROOT . DS . 'libraries' . DS . 'default' . DS . 'RequestProcessors' . DS . 'GenericRequestProcessor.php');
|
||||
|
||||
|
@ -30,9 +31,9 @@ class LocalToolRequestProcessor extends GenericRequestProcessor
|
|||
return parent::create($requestData);
|
||||
}
|
||||
|
||||
protected function assignProcessingTemplate($toolName)
|
||||
protected function assignProcessingTemplate($connectorName)
|
||||
{
|
||||
$processingTemplatePath = sprintf('%s/%s/%s.php', $this->scope, $toolName, $this->action);
|
||||
$processingTemplatePath = sprintf('%s/%s/%s.php', $this->scope, $connectorName, $this->action);
|
||||
$file = new File($this->processingTemplatesDirectory . DS . $processingTemplatePath);
|
||||
if ($file->exists()) {
|
||||
$this->processingTemplate = str_replace('.php', '', $processingTemplatePath);
|
||||
|
@ -40,10 +41,14 @@ class LocalToolRequestProcessor extends GenericRequestProcessor
|
|||
$file->close();
|
||||
}
|
||||
|
||||
protected function validateToolName($requestData)
|
||||
protected function validateConnectorName($requestData)
|
||||
{
|
||||
if (empty($requestData['data']['toolName'])) {
|
||||
throw new Exception('Error while validating request data. Tool name is missing.');
|
||||
if (empty($requestData['data']['connectorName'])) {
|
||||
throw new NotFoundException('Error while validating request data. Connector name is missing.');
|
||||
}
|
||||
$connector = $this->getConnectorFromClassname($requestData['data']['connectorName']);
|
||||
if (is_null($connector)) {
|
||||
throw new NotFoundException(__('Error while validating request data. Unkown connector `{0}`', $requestData['data']['connectorName']));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -55,14 +60,24 @@ class LocalToolRequestProcessor extends GenericRequestProcessor
|
|||
return $brood;
|
||||
}
|
||||
|
||||
protected function filterAlignmentsForBrood($individual, $brood)
|
||||
{
|
||||
foreach ($individual->alignments as $i => $alignment) {
|
||||
if ($alignment->organisation_id != $brood->organisation_id) {
|
||||
unset($individual->alignments[$i]);
|
||||
}
|
||||
}
|
||||
return $individual;
|
||||
}
|
||||
|
||||
protected function getConnector($request)
|
||||
{
|
||||
try {
|
||||
$connectorClasses = $this->LocalTools->getConnectorByToolName($request->local_tool_name);
|
||||
$connectorClasses = $this->LocalTools->getConnectors($request->local_tool_connector_name);
|
||||
if (!empty($connectorClasses)) {
|
||||
$connector = array_values($connectorClasses)[0];
|
||||
}
|
||||
} catch (Cake\Http\Exception\NotFoundException $e) {
|
||||
} catch (NotFoundException $e) {
|
||||
$connector = null;
|
||||
}
|
||||
return $connector;
|
||||
|
@ -71,23 +86,55 @@ class LocalToolRequestProcessor extends GenericRequestProcessor
|
|||
protected function getConnectorMeta($request)
|
||||
{
|
||||
try {
|
||||
$connectorClasses = $this->LocalTools->getConnectorByToolName($request->local_tool_name);
|
||||
if (!empty($connectorClasses)) {
|
||||
$connectorMeta = $this->LocalTools->extractMeta($connectorClasses)[0];
|
||||
}
|
||||
} catch (Cake\Http\Exception\NotFoundException $e) {
|
||||
$connectorMeta = null;
|
||||
$className = $request->local_tool_connector_name;
|
||||
$connector = $this->getConnectorFromClassname($className);
|
||||
$connectorMeta = $this->LocalTools->extractMeta([$className => $connector])[0];
|
||||
} catch (NotFoundException $e) {
|
||||
$connectorMeta = [];
|
||||
}
|
||||
return !is_null($connectorMeta) ? $connectorMeta : [];
|
||||
return $connectorMeta;
|
||||
}
|
||||
|
||||
protected function getConnectorFromClassname($className)
|
||||
{
|
||||
try {
|
||||
$connectorClasses = $this->LocalTools->getConnectors($className);
|
||||
if (!empty($connectorClasses)) {
|
||||
$connector = array_values($connectorClasses)[0];
|
||||
}
|
||||
} catch (NotFoundException $e) {
|
||||
$connector = null;
|
||||
}
|
||||
return $connector;
|
||||
}
|
||||
|
||||
protected function getConnectorMetaFromClassname($className)
|
||||
{
|
||||
try {
|
||||
$connector = $this->getConnectorFromClassname($className);
|
||||
$connectorMeta = $this->LocalTools->extractMeta([$className => $connector])[0];
|
||||
} catch (NotFoundException $e) {
|
||||
$connectorMeta = [];
|
||||
}
|
||||
return $connectorMeta;
|
||||
}
|
||||
|
||||
protected function attachRequestAssociatedData($request)
|
||||
{
|
||||
$request->brood = $this->getIssuerBrood($request);
|
||||
$request->connector = $this->getConnectorMeta($request);
|
||||
$request->individual = $request->user->individual;
|
||||
$request->individual = $this->filterAlignmentsForBrood($request->individual, $request->brood);
|
||||
return $request;
|
||||
}
|
||||
|
||||
protected function addBaseValidatorRules($validator)
|
||||
{
|
||||
return $validator
|
||||
->requirePresence('toolName')
|
||||
->notEmpty('toolName', 'A url must be provided')
|
||||
->requirePresence('url') // url -> cerebrate_url
|
||||
->notEmpty('url', 'A url must be provided');
|
||||
->requirePresence('connectorName')
|
||||
->notEmpty('connectorName', 'The connector name must be provided')
|
||||
->requirePresence('cerebrateURL')
|
||||
->notEmpty('cerebrateURL', 'A url must be provided');
|
||||
// ->add('url', 'validFormat', [
|
||||
// 'rule' => 'url',
|
||||
// 'message' => 'URL must be valid'
|
||||
|
@ -110,16 +157,16 @@ class IncomingConnectionRequestProcessor extends LocalToolRequestProcessor imple
|
|||
}
|
||||
|
||||
public function create($requestData) {
|
||||
$this->validateToolName($requestData);
|
||||
$this->validateConnectorName($requestData);
|
||||
$this->validateRequestData($requestData);
|
||||
$requestData['title'] = __('Request for {0} Inter-connection', $requestData['local_tool_name']);
|
||||
$connectorMeta = $this->getConnectorMetaFromClassname($requestData['data']['connectorName']);
|
||||
$requestData['title'] = __('Request for {0} Inter-connection', $connectorMeta['name']);
|
||||
return parent::create($requestData);
|
||||
}
|
||||
|
||||
public function getViewVariables($request)
|
||||
{
|
||||
$request->brood = $this->getIssuerBrood($request);
|
||||
$request->connector = $this->getConnectorMeta($request);
|
||||
$request = $this->attachRequestAssociatedData($request);
|
||||
return [
|
||||
'request' => $request,
|
||||
'progressStep' => 0,
|
||||
|
@ -170,7 +217,7 @@ class IncomingConnectionRequestProcessor extends LocalToolRequestProcessor imple
|
|||
protected function acceptConnection($connector, $remoteCerebrate, $requestData)
|
||||
{
|
||||
$connectorResult = $connector->acceptConnection($requestData['data']);
|
||||
$connectorResult['toolName'] = $requestData->local_tool_name;
|
||||
$connectorResult['connectorName'] = $requestData->local_tool_name;
|
||||
$response = $this->sendAcceptedRequestToRemote($remoteCerebrate, $connectorResult);
|
||||
// change state if sending fails
|
||||
// add the entry to the outbox if sending fails.
|
||||
|
@ -180,7 +227,7 @@ class IncomingConnectionRequestProcessor extends LocalToolRequestProcessor imple
|
|||
protected function declineConnection($connector, $remoteCerebrate, $requestData)
|
||||
{
|
||||
$connectorResult = $connector->declineConnection($requestData['data']);
|
||||
$connectorResult['toolName'] = $requestData->local_tool_name;
|
||||
$connectorResult['connectorName'] = $requestData->local_tool_name;
|
||||
$response = $this->sendDeclinedRequestToRemote($remoteCerebrate, $connectorResult);
|
||||
return $response;
|
||||
}
|
||||
|
@ -216,16 +263,16 @@ class AcceptedRequestProcessor extends LocalToolRequestProcessor implements Gene
|
|||
}
|
||||
|
||||
public function create($requestData) {
|
||||
$this->validateToolName($requestData);
|
||||
$this->validateConnectorName($requestData);
|
||||
$this->validateRequestData($requestData);
|
||||
$requestData['title'] = __('Inter-connection for {0} has been accepted', $requestData['local_tool_name']);
|
||||
$connectorMeta = $this->getConnectorMetaFromClassname($requestData['data']['connectorName']);
|
||||
$requestData['title'] = __('Inter-connection for {0} has been accepted', $connectorMeta['name']);
|
||||
return parent::create($requestData);
|
||||
}
|
||||
|
||||
public function getViewVariables($request)
|
||||
{
|
||||
$request->brood = $this->getIssuerBrood($request);
|
||||
$request->connector = $this->getConnectorMeta($request);
|
||||
$request = $this->attachRequestAssociatedData($request);
|
||||
return [
|
||||
'request' => $request,
|
||||
'progressStep' => 1,
|
||||
|
@ -280,16 +327,16 @@ class DeclinedRequestProcessor extends LocalToolRequestProcessor implements Gene
|
|||
}
|
||||
|
||||
public function create($requestData) {
|
||||
$this->validateToolName($requestData);
|
||||
$this->validateConnectorName($requestData);
|
||||
$this->validateRequestData($requestData);
|
||||
$requestData['title'] = __('Declined inter-connection for {0}', $requestData['local_tool_name']);
|
||||
$connectorMeta = $this->getConnectorMetaFromClassname($requestData['data']['connectorName']);
|
||||
$requestData['title'] = __('Declined inter-connection for {0}', $connectorMeta['name']);
|
||||
return parent::create($requestData);
|
||||
}
|
||||
|
||||
public function getViewVariables($request)
|
||||
{
|
||||
$request->brood = $this->getIssuerBrood($request);
|
||||
$request->connector = $this->getConnectorMeta($request);
|
||||
$request = $this->attachRequestAssociatedData($request);
|
||||
return [
|
||||
'request' => $request,
|
||||
'progressStep' => 1,
|
||||
|
|
|
@ -50,22 +50,38 @@ $footerButtons[] = [
|
|||
|
||||
$table = $this->Bootstrap->table(['small' => true, 'bordered' => false, 'striped' => false, 'hover' => false], [
|
||||
'fields' => [
|
||||
['key' => 'created', 'label' => __('Date'), 'formatter' => function($value, $row) {
|
||||
return $value->i18nFormat('yyyy-MM-dd HH:mm:ss');
|
||||
}],
|
||||
['key' => 'connector', 'label' => __('Tool Name'), 'formatter' => function($connector, $row) {
|
||||
return sprintf('<a href="%s" target="_blank">%s</a>',
|
||||
$this->Url->build(['controller' => 'localTools', 'action' => 'viewConnector', $connector['name']]),
|
||||
sprintf('%s (v%s)', h($connector['name']), h($connector['connector_version']))
|
||||
);
|
||||
}],
|
||||
['key' => 'created', 'label' => __('Date'), 'formatter' => function($value, $row) {
|
||||
return $value->i18nFormat('yyyy-MM-dd HH:mm:ss');
|
||||
}],
|
||||
['key' => 'origin', 'label' => __('Origin')],
|
||||
['key' => 'brood', 'label' => __('Brood'), 'formatter' => function($brood, $row) {
|
||||
return sprintf('<a href="%s" target="_blank">%s</a>',
|
||||
$this->Url->build(['controller' => 'broods', 'action' => 'view', $brood['id']]),
|
||||
h($brood['name'])
|
||||
);
|
||||
}]
|
||||
}],
|
||||
['key' => 'individual', 'label' => __('Individual'), 'formatter' => function($individual, $row) {
|
||||
return sprintf('<a href="%s" target="_blank">%s</a>',
|
||||
$this->Url->build(['controller' => 'users', 'action' => 'view', $individual['id']]),
|
||||
h($individual['email'])
|
||||
);
|
||||
}],
|
||||
['key' => 'individual.alignments', 'label' => __('Alignment'), 'formatter' => function($alignments, $row) {
|
||||
$html = '';
|
||||
foreach ($alignments as $alignment) {
|
||||
$html .= sprintf('<div class="text-nowrap"><b>%s</b> @ <a href="%s" target="_blank">%s</a></div>',
|
||||
h($alignment['type']),
|
||||
$this->Url->build(['controller' => 'users', 'action' => 'view', $alignment['organisation']['id']]),
|
||||
h($alignment['organisation']['name'])
|
||||
);
|
||||
}
|
||||
return $html;
|
||||
}],
|
||||
],
|
||||
'items' => [$request->toArray()],
|
||||
]);
|
||||
|
@ -103,8 +119,8 @@ $bodyHtml = sprintf('<div class="py-2"><div>%s</div>%s</div><div class="d-none">
|
|||
);
|
||||
|
||||
echo $this->Bootstrap->modal([
|
||||
'title' => __('Interconnection Request for {0}', h($request->data['toolName'])),
|
||||
'size' => 'lg',
|
||||
'title' => __('Interconnection Request for {0}', h($request->local_tool_connector_name)),
|
||||
'size' => 'xl',
|
||||
'type' => 'custom',
|
||||
'bodyHtml' => sprintf('<div class="p-3">%s</div><div class="description-container">%s</div>',
|
||||
$progress,
|
||||
|
|
|
@ -6,6 +6,7 @@ use App\Controller\AppController;
|
|||
use Cake\Database\Expression\QueryExpression;
|
||||
use Cake\Event\EventInterface;
|
||||
use Cake\ORM\TableRegistry;
|
||||
use Cake\Utility\Inflector;
|
||||
use Cake\Utility\Hash;
|
||||
use Cake\Utility\Text;
|
||||
use Cake\Http\Exception\NotFoundException;
|
||||
|
@ -59,7 +60,7 @@ class InboxController extends AppController
|
|||
public function delete($id)
|
||||
{
|
||||
if ($this->request->is('post')) {
|
||||
$request = $this->Inbox->get($id);
|
||||
$request = $this->Inbox->get($id, ['contain' => ['Users' => ['Individuals' => ['Alignments' => 'Organisations']]]]);
|
||||
$this->requestProcessor = TableRegistry::getTableLocator()->get('RequestProcessor');
|
||||
$processor = $this->requestProcessor->getProcessor($request->scope, $request->action);
|
||||
$discardResult = $processor->discard($id, $request);
|
||||
|
@ -77,7 +78,7 @@ class InboxController extends AppController
|
|||
|
||||
public function process($id)
|
||||
{
|
||||
$request = $this->Inbox->get($id);
|
||||
$request = $this->Inbox->get($id, ['contain' => ['Users' => ['Individuals' => ['Alignments' => 'Organisations']]]]);
|
||||
$scope = $request->scope;
|
||||
$action = $request->action;
|
||||
$this->requestProcessor = TableRegistry::getTableLocator()->get('RequestProcessor');
|
||||
|
@ -129,22 +130,33 @@ class InboxController extends AppController
|
|||
];
|
||||
$entryData['data'] = $this->request->data ?? [];
|
||||
// $entryData['data'] = [
|
||||
// 'toolName' => 'MISP',
|
||||
// 'url' => 'http://localhost:8000',
|
||||
// 'connectorName' => 'MispConnector',
|
||||
// 'cerebrateURL' => 'http://localhost:8000',
|
||||
// 'url' => 'https://localhost:8443',
|
||||
// 'email' => 'admin@admin.test',
|
||||
// 'authkey' => 'DkM9fEfwrG8Bg3U0ncKamocIutKt5YaUFuxzsB6b',
|
||||
// ];
|
||||
$this->requestProcessor = TableRegistry::getTableLocator()->get('RequestProcessor');
|
||||
if ($scope == 'LocalTool') {
|
||||
if (empty($entryData['data']['toolName']) || empty($entryData['data']['url'])) {
|
||||
throw new MethodNotAllowedException(__('Could not create entry. Tool name or URL is missing'));
|
||||
$this->validateLocalToolRequestEntry($entryData);
|
||||
$entryData['origin'] = $entryData['data']['cerebrateURL'];
|
||||
$processor = $this->requestProcessor->getLocalToolProcessor($action, $entryData['data']['connectorName']);
|
||||
$errors = $this->Inbox->checkUserBelongsToBroodOwnerOrg($this->ACL->getUser(), $entryData);
|
||||
if (!empty($errors)) {
|
||||
$message = __('Could not create inbox message');
|
||||
return $this->RestResponse->ajaxFailResponse(Inflector::singularize($this->Inbox->getAlias()), 'createInboxEntry', [], $message, $errors);
|
||||
}
|
||||
$entryData['origin'] = $entryData['data']['url'];
|
||||
$processor = $this->requestProcessor->getLocalToolProcessor($action, $entryData['data']['toolName']);
|
||||
} else {
|
||||
$processor = $this->requestProcessor->getProcessor($scope, $action);
|
||||
}
|
||||
$creationResult = $this->requestProcessor->createInboxEntry($processor, $entryData);
|
||||
return $processor->genHTTPReply($this, $creationResult);
|
||||
}
|
||||
|
||||
private function validateLocalToolRequestEntry($entryData)
|
||||
{
|
||||
if (empty($entryData['data']['connectorName']) || empty($entryData['data']['cerebrateURL'])) {
|
||||
throw new MethodNotAllowedException(__('Could not create entry. Tool name or URL is missing'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -186,8 +186,9 @@ class LocalToolsController extends AppController
|
|||
},
|
||||
'afterFind' => function($data) {
|
||||
foreach ($data as $connector) {
|
||||
$connectorClass = array_values($this->LocalTools->getConnectorByConnectionId($connector['id']))[0];
|
||||
$connector['toolName'] = $connectorClass->name;
|
||||
$connectorById = $this->LocalTools->getConnectorByConnectionId($connector['id'])
|
||||
$className = array_keys($connectorById)[0];
|
||||
$connector['connectorName'] = $className;
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
@ -222,18 +223,12 @@ class LocalToolsController extends AppController
|
|||
$this->loadModel('Broods');
|
||||
$remoteCerebrate = $this->Broods->find()->where(['id' => $params['cerebrate_id']])->first();
|
||||
if ($this->request->is(['post', 'put'])) {
|
||||
$postParams = $this->ParamHandler->harvestParams(['local_tool_id', 'tool_name']);
|
||||
$postParams = $this->ParamHandler->harvestParams(['local_tool_id']);
|
||||
if (empty($postParams['local_tool_id'])) {
|
||||
throw new MethodNotAllowedException(__('No local tool ID supplied.'));
|
||||
}
|
||||
if (empty($postParams['tool_name'])) {
|
||||
throw new MethodNotAllowedException(__('No local tool name supplied.'));
|
||||
}
|
||||
$params['local_tool_id'] = $postParams['local_tool_id'];
|
||||
$encodingResult = $this->LocalTools->encodeConnection($params);
|
||||
$encodingResult['toolName'] = $tool_name;
|
||||
$urlPath = '/inbox/createInboxEntry/LocalTool/IncomingConnectionRequest';
|
||||
$response = $this->Inbox->sendRequest($remoteCerebrate, $urlPath, true, $encodingResult);
|
||||
$this->redirect();
|
||||
} else {
|
||||
$remoteTool = $this->LocalTools->getRemoteToolById($params);
|
||||
|
|
|
@ -7,13 +7,13 @@ use Cake\ORM\Entity;
|
|||
|
||||
class Inbox extends AppModel
|
||||
{
|
||||
protected $_virtual = ['local_tool_name'];
|
||||
protected $_virtual = ['local_tool_connector_name'];
|
||||
|
||||
protected function _getLocalToolName()
|
||||
protected function _getLocalToolConnectorName()
|
||||
{
|
||||
$localToolName = null;
|
||||
if (!empty($this->data) && !empty($this->data['toolName'])) {
|
||||
$localToolName = $this->data['toolName'];
|
||||
if (!empty($this->data) && !empty($this->data['connectorName'])) {
|
||||
$localToolName = $this->data['connectorName'];
|
||||
}
|
||||
return $localToolName;
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ use Cake\Database\Type;
|
|||
use Cake\ORM\Table;
|
||||
use Cake\ORM\RulesChecker;
|
||||
use Cake\Validation\Validator;
|
||||
use Cake\Http\Exception\NotFoundException;
|
||||
|
||||
Type::map('json', 'Cake\Database\Type\JsonType');
|
||||
|
||||
|
@ -44,7 +45,7 @@ class InboxTable extends AppTable
|
|||
->notEmptyString('title')
|
||||
->notEmptyString('origin')
|
||||
->datetime('created')
|
||||
|
||||
|
||||
->requirePresence([
|
||||
'scope' => ['message' => __('The field `scope` is required')],
|
||||
'action' => ['message' => __('The field `action` is required')],
|
||||
|
@ -85,4 +86,27 @@ class InboxTable extends AppTable
|
|||
throw new NotFoundException(__('Could not post to the requested resource.'));
|
||||
}
|
||||
}
|
||||
|
||||
public function checkUserBelongsToBroodOwnerOrg($user, $entryData) {
|
||||
$this->Broods = \Cake\ORM\TableRegistry::getTableLocator()->get('Broods');
|
||||
$this->Individuals = \Cake\ORM\TableRegistry::getTableLocator()->get('Individuals');
|
||||
$errors = [];
|
||||
$brood = $this->Broods->find()
|
||||
->where(['url' => $entryData['origin']])
|
||||
->first();
|
||||
if (empty($brood)) {
|
||||
$errors[] = __('Unkown brood `{0}`', $entryData['data']['cerebrateURL']);
|
||||
}
|
||||
|
||||
$found = false;
|
||||
foreach ($user->individual->organisations as $organisations) {
|
||||
if ($organisations->id == $brood->organisation_id) {
|
||||
$found = true;
|
||||
}
|
||||
}
|
||||
if (!$found) {
|
||||
$errors[] = __('User is not part of the brood organisation');
|
||||
}
|
||||
return $errors;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -206,6 +206,7 @@ class LocalToolsTable extends AppTable
|
|||
{
|
||||
$params = $this->buildConnectionParams($params);
|
||||
$result = $params['connector'][$params['remote_tool']['connector']]->initiateConnectionWrapper($params);
|
||||
$this->sendEncodedConnection($params['remote_cerebrate'], $params['remote_tool']['connector'], $result);
|
||||
return $result;
|
||||
}
|
||||
|
||||
|
@ -242,4 +243,13 @@ class LocalToolsTable extends AppTable
|
|||
}
|
||||
return $local_tools;
|
||||
}
|
||||
|
||||
public function sendEncodedConnection($remoteCerebrate, $connectorName, $encodedConnection)
|
||||
{
|
||||
$encodedConnection['connectorName'] = $connectorName;
|
||||
$encodedConnection['cerebrateURL'] = Configure::read('App.fullBaseUrl');
|
||||
$urlPath = '/inbox/createInboxEntry/LocalTool/IncomingConnectionRequest';
|
||||
$response = $this->Inbox->sendRequest($remoteCerebrate, $urlPath, true, $encodedConnection);
|
||||
// If sending failed: Modify state + add entry in outbox
|
||||
}
|
||||
}
|
||||
|
|
|
@ -52,10 +52,10 @@ class RequestProcessorTable extends AppTable
|
|||
throw new MissingRequestProcessorException(__('Processor not found'));
|
||||
}
|
||||
|
||||
public function getLocalToolProcessor($action, $toolName)
|
||||
public function getLocalToolProcessor($action, $connectorName)
|
||||
{
|
||||
$scope = "LocalTool";
|
||||
$specificScope = "{$toolName}LocalTool";
|
||||
$specificScope = "{$connectorName}LocalTool";
|
||||
try { // try to get specific processor for module name or fall back to generic local tool processor
|
||||
$processor = $this->getProcessor($specificScope, $action);
|
||||
} catch (MissingRequestProcessorException $e) {
|
||||
|
|
Loading…
Reference in New Issue