From e1ebbc125a0b963fde7dd7a6db6a27f491eda411 Mon Sep 17 00:00:00 2001 From: mokaddem Date: Thu, 17 Jun 2021 14:13:10 +0200 Subject: [PATCH] chg: [inbox:localTool] Usage of localTools in the inbox to process connection requests - WiP --- .../LocalToolRequestProcessor.php | 103 +++++++++++++----- src/Controller/AppController.php | 4 + src/Controller/InboxController.php | 9 +- src/Controller/LocalToolsController.php | 22 +++- .../CommonConnectorTools.php | 1 + .../local_tool_connectors/MispConnector.php | 71 +++++++----- src/Model/Table/BroodsTable.php | 56 ++++++++++ src/Model/Table/InboxTable.php | 25 +---- src/Model/Table/LocalToolsTable.php | 38 +++++-- webroot/js/bootstrap-helper.js | 16 ++- 10 files changed, 239 insertions(+), 106 deletions(-) diff --git a/libraries/default/RequestProcessors/LocalToolRequestProcessor.php b/libraries/default/RequestProcessors/LocalToolRequestProcessor.php index 0320195..e5160bf 100644 --- a/libraries/default/RequestProcessors/LocalToolRequestProcessor.php +++ b/libraries/default/RequestProcessors/LocalToolRequestProcessor.php @@ -60,6 +60,13 @@ class LocalToolRequestProcessor extends GenericRequestProcessor return $brood; } + protected function getConnection($requestData) + { + $local_tool_id = $requestData['remote_tool_id']; // local_tool_id is actually the remote_tool_id for the sender + $connection = $this->LocalTools->find()->where(['id' => $local_tool_id])->first(); + return $connection; + } + protected function filterAlignmentsForBrood($individual, $brood) { foreach ($individual->alignments as $i => $alignment) { @@ -128,13 +135,34 @@ class LocalToolRequestProcessor extends GenericRequestProcessor return $request; } + protected function genBroodParam($remoteCerebrate, $connection, $connector, $requestData) + { + $local_tool_id = $requestData['remote_tool_id']; // local_tool_id is actually the remote_tool_id for the sender + $remote_tool_id = $requestData['local_tool_id']; // remote_tool_id is actually the local_tool_id for the sender + $remote_org = $this->Broods->Organisations->find()->where(['id' => $remoteCerebrate->organisation_id])->first(); + return [ + 'remote_tool' => [ + 'id' => $remote_tool_id, + 'connector' => $connector->connectorName, + ], + 'remote_org' => $remote_org, + 'remote_tool_data' => $requestData, + 'remote_cerebrate' => $remoteCerebrate, + 'connection' => $connection, + ]; + } + protected function addBaseValidatorRules($validator) { return $validator ->requirePresence('connectorName') ->notEmpty('connectorName', 'The connector name must be provided') ->requirePresence('cerebrateURL') - ->notEmpty('cerebrateURL', 'A url must be provided'); + ->notEmpty('cerebrateURL', 'A url must be provided') + ->requirePresence('local_tool_id') + ->numeric('local_tool_id', 'A local_tool_id must be provided') + ->requirePresence('remote_tool_id') + ->numeric('remote_tool_id', 'A remote_tool_id must be provided'); // ->add('url', 'validFormat', [ // 'rule' => 'url', // 'message' => 'URL must be valid' @@ -191,18 +219,23 @@ class IncomingConnectionRequestProcessor extends LocalToolRequestProcessor imple $this->discard($id, $inboxRequest); } } else { - $connectorResult = $this->acceptConnection($connector, $remoteCerebrate, $inboxRequest['data']); - $connectionSuccessfull = false; - $connectionData = []; - $resultTitle = __('Could not inter-connect `{0}`\'s {1}', $inboxRequest['origin'], $inboxRequest['local_tool_name']); $errors = []; + $connectorResult = []; + try { + $connectorResult = $this->acceptConnection($connector, $remoteCerebrate, $inboxRequest['data']); + $connectionSuccessfull = true; + } catch (\Throwable $th) { + $connectionSuccessfull = false; + $errors = $th->getMessage(); + } + $resultTitle = __('Could not inter-connect `{0}`\'s {1}', $inboxRequest['origin'], $inboxRequest['local_tool_name']); if ($connectionSuccessfull) { $resultTitle = __('Interconnection for `{0}`\'s {1} created', $inboxRequest['origin'], $inboxRequest['local_tool_name']); $this->discard($id, $inboxRequest); } } return $this->genActionResult( - $connectionData, + $connectorResult, $connectionSuccessfull, $resultTitle, $errors @@ -216,34 +249,36 @@ class IncomingConnectionRequestProcessor extends LocalToolRequestProcessor imple protected function acceptConnection($connector, $remoteCerebrate, $requestData) { - $connectorResult = $connector->acceptConnection($requestData['data']); - $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. + $connection = $this->getConnection($requestData); + $params = $this->genBroodParam($remoteCerebrate, $connection, $connector, $requestData); + $connectorResult = $connector->acceptConnection($params); + $response = $this->sendAcceptedRequestToRemote($params, $connectorResult); return $response; } protected function declineConnection($connector, $remoteCerebrate, $requestData) { - $connectorResult = $connector->declineConnection($requestData['data']); - $connectorResult['connectorName'] = $requestData->local_tool_name; - $response = $this->sendDeclinedRequestToRemote($remoteCerebrate, $connectorResult); + $connection = $this->getConnection($requestData); + $params = $this->genBroodParam($remoteCerebrate, $connection, $connector, $requestData); + $connectorResult = $connector->declineConnection($params); + $response = $this->sendDeclinedRequestToRemote($params, $connectorResult); return $response; } - protected function sendAcceptedRequestToRemote($remoteCerebrate, $connectorResult) + protected function sendAcceptedRequestToRemote($params, $connectorResult) { - $urlPath = '/inbox/createInboxEntry/LocalTool/AcceptedRequest'; - $response = $this->Inbox->sendRequest($remoteCerebrate, $urlPath, true, $connectorResult); - return $response; + $response = $this->Broods->sendLocalToolAcceptedRequest($params, $connectorResult); + // change state if sending fails + // add the entry to the outbox if sending fails. + return $response->getJson(); } protected function sendDeclinedRequestToRemote($remoteCerebrate, $connectorResult) { - $urlPath = '/inbox/createInboxEntry/LocalTool/DeclinedRequest'; - $response = $this->Inbox->sendRequest($remoteCerebrate, $urlPath, true, $connectorResult); - return $response; + $response = $this->Broods->sendLocalToolDeclinedRequest($params, $connectorResult); + // change state if sending fails + // add the entry to the outbox if sending fails. + return $response->getJson(); } } @@ -254,7 +289,6 @@ class AcceptedRequestProcessor extends LocalToolRequestProcessor implements Gene public function __construct() { parent::__construct(); $this->description = __('Handle Phase II of inter-connection when initial request has been accepted by the remote cerebrate.'); - // $this->Broods = TableRegistry::getTableLocator()->get('Broods'); } protected function addValidatorRules($validator) @@ -281,15 +315,22 @@ class AcceptedRequestProcessor extends LocalToolRequestProcessor implements Gene public function process($id, $requestData, $inboxRequest) { - $connector = $this->getConnector($request); - $remoteCerebrate = $this->getIssuerBrood($request); - $connectorResult = $this->finalizeConnection($connector, $remoteCerebrate, $requestData['data']); - $connectionSuccessfull = false; - $connectionData = []; - $resultTitle = __('Could not finalize inter-connection for `{0}`\'s {1}', $requestData['origin'], $requestData['local_tool_name']); + $connector = $this->getConnector($inboxRequest); + $remoteCerebrate = $this->getIssuerBrood($inboxRequest); + $errors = []; + $connectorResult = []; + try { + $connectorResult = $this->finalizeConnection($connector, $remoteCerebrate, $inboxRequest['data']); + $connectionSuccessfull = true; + } catch (\Throwable $th) { + $connectionSuccessfull = false; + $errors = $th->getMessage(); + } + $connectionData = []; + $resultTitle = __('Could not finalize inter-connection for `{0}`\'s {1}', $inboxRequest['origin'], $inboxRequest['local_tool_name']); if ($connectionSuccessfull) { - $resultTitle = __('Interconnection for `{0}`\'s {1} finalized', $requestData['origin'], $requestData['local_tool_name']); + $resultTitle = __('Interconnection for `{0}`\'s {1} finalized', $inboxRequest['origin'], $inboxRequest['local_tool_name']); $this->discard($id, $requestData); } return $this->genActionResult( @@ -307,7 +348,9 @@ class AcceptedRequestProcessor extends LocalToolRequestProcessor implements Gene protected function finalizeConnection($connector, $remoteCerebrate, $requestData) { - $connectorResult = $connector->finaliseConnection($requestData['data']); + $connection = $this->getConnection($requestData); + $params = $this->genBroodParam($remoteCerebrate, $connection, $connector, $requestData); + $connectorResult = $connector->finaliseConnection($params); return $connectorResult; } } diff --git a/src/Controller/AppController.php b/src/Controller/AppController.php index 70116d9..d1cc910 100644 --- a/src/Controller/AppController.php +++ b/src/Controller/AppController.php @@ -112,6 +112,10 @@ class AppController extends Controller $this->Security->setConfig('validatePost', false); } $this->Security->setConfig('unlockedActions', ['index']); + if ($this->ParamHandler->isRest()) { + $this->Security->setConfig('unlockedActions', [$this->request->getParam('action')]); + $this->Security->setConfig('validatePost', false); + } $this->ACL->checkAccess(); $this->set('menu', $this->ACL->getMenu()); diff --git a/src/Controller/InboxController.php b/src/Controller/InboxController.php index c404c0e..f69995c 100644 --- a/src/Controller/InboxController.php +++ b/src/Controller/InboxController.php @@ -128,14 +128,7 @@ class InboxController extends AppController 'origin' => $this->request->clientIp(), 'user_id' => $this->ACL->getUser()['id'], ]; - $entryData['data'] = $this->request->data ?? []; - // $entryData['data'] = [ - // 'connectorName' => 'MispConnector', - // 'cerebrateURL' => 'http://localhost:8000', - // 'url' => 'https://localhost:8443', - // 'email' => 'admin@admin.test', - // 'authkey' => 'DkM9fEfwrG8Bg3U0ncKamocIutKt5YaUFuxzsB6b', - // ]; + $entryData['data'] = $this->request->getData() ?? []; $this->requestProcessor = TableRegistry::getTableLocator()->get('RequestProcessor'); if ($scope == 'LocalTool') { $this->validateLocalToolRequestEntry($entryData); diff --git a/src/Controller/LocalToolsController.php b/src/Controller/LocalToolsController.php index a49e59f..835b8a1 100644 --- a/src/Controller/LocalToolsController.php +++ b/src/Controller/LocalToolsController.php @@ -229,7 +229,27 @@ class LocalToolsController extends AppController } $params['local_tool_id'] = $postParams['local_tool_id']; $encodingResult = $this->LocalTools->encodeConnection($params); - $this->redirect(); + $inboxResult = $encodingResult['inboxResult']; + if ($inboxResult['success']) { + if ($this->ParamHandler->isRest()) { + $response = $this->RestResponse->viewData($inboxResult, 'json'); + } else if ($this->ParamHandler->isAjax()) { + $response = $this->RestResponse->ajaxSuccessResponse('LocalTool', 'connectionRequest', [], $inboxResult['message']); + } else { + $this->Flash->success($inboxResult['message']); + $this->redirect(['action' => 'broodTools', $cerebrate_id]); + } + } else { + if ($this->ParamHandler->isRest()) { + $response = $this->RestResponse->viewData($inboxResult, 'json'); + } else if ($this->ParamHandler->isAjax()) { + $response = $this->RestResponse->ajaxFailResponse('LocalTool', 'connectionRequest', [], $inboxResult['message'], $inboxResult['errors']); + } else { + $this->Flash->error($inboxResult['message']); + $this->redirect($this->referer()); + } + } + return $response; } else { $remoteTool = $this->LocalTools->getRemoteToolById($params); $local_tools = $this->LocalTools->encodeConnectionChoice($params); diff --git a/src/Lib/default/local_tool_connectors/CommonConnectorTools.php b/src/Lib/default/local_tool_connectors/CommonConnectorTools.php index acfc2e3..8999d6d 100644 --- a/src/Lib/default/local_tool_connectors/CommonConnectorTools.php +++ b/src/Lib/default/local_tool_connectors/CommonConnectorTools.php @@ -7,6 +7,7 @@ class CommonConnectorTools { public $description = ''; public $name = ''; + public $connectorName = ''; public $exposedFunctions = [ 'diagnostics' ]; diff --git a/src/Lib/default/local_tool_connectors/MispConnector.php b/src/Lib/default/local_tool_connectors/MispConnector.php index 35c6ccc..1d3457c 100644 --- a/src/Lib/default/local_tool_connectors/MispConnector.php +++ b/src/Lib/default/local_tool_connectors/MispConnector.php @@ -11,6 +11,7 @@ class MispConnector extends CommonConnectorTools { public $description = 'MISP connector, handling diagnostics, organisation and sharing group management of your instance. Synchronisation requests can also be managed through the connector.'; + public $connectorName = 'MispConnector'; public $name = 'MISP'; public $exposedFunctions = [ @@ -100,16 +101,19 @@ class MispConnector extends CommonConnectorTools } } - public function getHTTPClient(Object $connection): Object + private function getHeaders(array $connectionSettings): array + { + return [ + 'AUTHORIZATION' => $connectionSettings['authkey'], + 'Accept' => 'application/json', + 'Content-type' => 'application/json' + ]; + } + + private function getHTTPClient(Object $connection): Object { $settings = json_decode($connection->settings, true); - $options = [ - 'headers' => [ - 'AUTHORIZATION' => $settings['authkey'], - 'Accept' => 'Application/json', - 'Content-type' => 'Application/json' - ], - ]; + $options = []; if (!empty($settings['skip_ssl'])) { $options['ssl_verify_peer'] = false; $options['ssl_verify_host'] = false; @@ -126,7 +130,12 @@ class MispConnector extends CommonConnectorTools $http = $this->getHTTPClient($connection); try { - $response = $http->post($settings['url'] . '/users/view/me.json', '{}'); + $response = $http->post($settings['url'] . '/users/view/me.json', '{}', ['headers' => [ + 'AUTHORIZATION' => $settings['authkey'], + 'Accept' => 'application/json', + ], + 'type' => 'json', + ]); } catch (\Exception $e) { return [ 'status' => 0, @@ -171,7 +180,7 @@ class MispConnector extends CommonConnectorTools 'AUTHORIZATION' => $settings['authkey'], 'Accept' => 'application/json', 'Content-type' => 'application/json' - ] + ] ]); if ($response->isOk()) { return $response; @@ -193,15 +202,15 @@ class MispConnector extends CommonConnectorTools $url = $this->urlAppendParams($url, $params); $response = $http->post($settings['url'] . $url, json_encode($params['body']), [ 'headers' => [ - 'AUTHORIZATION' => $settings['authkey'], - 'Accept' => 'application/json' + 'AUTHORIZATION' => $settings['authkey'], + 'Accept' => 'application/json', ], - 'type' => 'json' + 'type' => 'json', ]); if ($response->isOk()) { return $response; } else { - throw new NotFoundException(__('Could not post to the requested resource.')); + throw new NotFoundException(__('Could not post to the requested resource. Remote returned:') . PHP_EOL . $response->getStringBody()); } } @@ -701,12 +710,14 @@ class MispConnector extends CommonConnectorTools $params['connection_settings'] = json_decode($params['connection']['settings'], true); $params['misp_organisation'] = $this->getSetOrg($params); $params['sync_user'] = $this->createSyncUser($params); - $params['sync_connection'] = $this->addServer([ - 'authkey' => $params['remote_tool']['authkey'], - 'url' => $params['remote_tool']['url'], - 'name' => $params['remote_tool']['name'], + $serverParams = $params; + $serverParams['body'] = [ + 'authkey' => $params['remote_tool_data']['authkey'], + 'url' => $params['remote_tool_data']['url'], + 'name' => !empty($params['remote_tool_data']['name']) ? $params['remote_tool_data']['name'] : 'Empty name fix me', 'remote_org_id' => $params['misp_organisation']['id'] - ]); + ]; + $params['sync_connection'] = $this->addServer($serverParams); return [ 'email' => $params['sync_user']['email'], 'authkey' => $params['sync_user']['authkey'], @@ -716,12 +727,15 @@ class MispConnector extends CommonConnectorTools public function finaliseConnection(array $params): bool { - $params['sync_connection'] = $this->addServer([ - 'authkey' => $params['remote_tool']['authkey'], - 'url' => $params['remote_tool']['url'], - 'name' => $params['remote_tool']['name'], + $params['misp_organisation'] = $this->getSetOrg($params); + $serverParams = $params; + $serverParams['body'] = [ + 'authkey' => $params['remote_tool_data']['authkey'], + 'url' => $params['remote_tool_data']['url'], + 'name' => !empty($params['remote_tool_data']['name']) ? $params['remote_tool_data']['name'] : 'Empty name fix me', 'remote_org_id' => $params['misp_organisation']['id'] - ]); + ]; + $params['sync_connection'] = $this->addServer($serverParams); return true; } @@ -733,6 +747,7 @@ class MispConnector extends CommonConnectorTools $organisation = $response->getJson()['Organisation']; if (!$organisation['local']) { $organisation['local'] = 1; + $params['body'] = $organisation; $response = $this->postData('/admin/organisations/edit/' . $organisation['id'], $params); if (!$response->isOk()) { throw new MethodNotAllowedException(__('Could not update the organisation in MISP.')); @@ -771,10 +786,10 @@ class MispConnector extends CommonConnectorTools private function addServer(array $params): array { if ( - empty($params['authkey']) || - empty($params['url']) || - empty($params['remote_org_id']) || - empty($params['name']) + empty($params['body']['authkey']) || + empty($params['body']['url']) || + empty($params['body']['remote_org_id']) || + empty($params['body']['name']) ) { throw new MethodNotAllowedException(__('Required data missing from the sync connection object. The following fields are required: [name, url, authkey, org_id].')); } diff --git a/src/Model/Table/BroodsTable.php b/src/Model/Table/BroodsTable.php index 98a62ae..7113d62 100644 --- a/src/Model/Table/BroodsTable.php +++ b/src/Model/Table/BroodsTable.php @@ -5,7 +5,10 @@ namespace App\Model\Table; use App\Model\Table\AppTable; use Cake\ORM\Table; use Cake\Validation\Validator; +use Cake\Core\Configure; use Cake\Http\Client; +use Cake\Http\Client\Response; +use Cake\Http\Exception\NotFoundException; use Cake\ORM\TableRegistry; use Cake\Error\Debugger; @@ -192,4 +195,57 @@ class BroodsTable extends AppTable return false; } } + + public function sendRequest($brood, $urlPath, $methodPost = true, $data = []): Response + { + $http = new Client(); + $config = [ + 'headers' => [ + 'AUTHORIZATION' => $brood->authkey, + 'Accept' => 'application/json' + ], + 'type' => 'json' + ]; + $url = $brood->url . $urlPath; + if ($methodPost) { + $response = $http->post($url, json_encode($data), $config); + } else { + $response = $http->get($brood->url, $data, $config); + } + if ($response->isOk()) { + return $response; + } else { + throw new NotFoundException(__('Could not send to the requested resource.')); + } + } + + private function injectRequiredData($params, $data): Array + { + $data['connectorName'] = $params['remote_tool']['connector']; + $data['cerebrateURL'] = Configure::read('App.fullBaseUrl'); + $data['local_tool_id'] = $params['connection']['id']; + $data['remote_tool_id'] = $params['remote_tool']['id']; + return $data; + } + + public function sendLocalToolConnectionRequest($params, $data): Response + { + $url = '/inbox/createInboxEntry/LocalTool/IncomingConnectionRequest'; + $data = $this->injectRequiredData($params, $data); + return $this->sendRequest($params['remote_cerebrate'], $url, true, $data); + } + + public function sendLocalToolAcceptedRequest($params, $data): Response + { + $url = '/inbox/createInboxEntry/LocalTool/AcceptedRequest'; + $data = $this->injectRequiredData($params, $data); + return $this->sendRequest($params['remote_cerebrate'], $url, true, $data); + } + + public function sendLocalToolDeclinedRequest($params, $data): Response + { + $url = '/inbox/createInboxEntry/LocalTool/DeclinedRequest'; + $data = $this->injectRequiredData($params, $data); + return $this->sendRequest($params['remote_cerebrate'], $url, true, $data); + } } diff --git a/src/Model/Table/InboxTable.php b/src/Model/Table/InboxTable.php index 5b0d8bf..1640bc2 100644 --- a/src/Model/Table/InboxTable.php +++ b/src/Model/Table/InboxTable.php @@ -64,29 +64,6 @@ class InboxTable extends AppTable return $rules; } - public function sendRequest($brood, $urlPath, $methodPost = true, $data = []): boolean - { - $http = new Client(); - $config = [ - 'headers' => [ - 'AUTHORIZATION' => $brood->authkey, - 'Accept' => 'application/json' - ], - 'type' => 'json' - ]; - $url = $brood->url . $urlPath; - if ($methodPost) { - $response = $http->post($url, json_encode(data), $config); - } else { - $response = $http->get($brood->url, json_encode(data), $config); - } - if ($response->isOk()) { - return $response; - } else { - 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'); @@ -105,7 +82,7 @@ class InboxTable extends AppTable } } if (!$found) { - $errors[] = __('User is not part of the brood organisation'); + $errors[] = __('User `{0}` is not part of the brood\'s organisation. Make sure `{0}` is aligned with the organisation owning the brood.', $user->individual->email); } return $errors; } diff --git a/src/Model/Table/LocalToolsTable.php b/src/Model/Table/LocalToolsTable.php index ea701d5..159878a 100644 --- a/src/Model/Table/LocalToolsTable.php +++ b/src/Model/Table/LocalToolsTable.php @@ -205,9 +205,12 @@ class LocalToolsTable extends AppTable public function encodeConnection(array $params): array { $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; + $localResult = $params['connector'][$params['remote_tool']['connector']]->initiateConnectionWrapper($params); + $inboxResult = $this->sendEncodedConnection($params, $localResult); + return [ + 'inboxResult' => $inboxResult, + 'localResult' => $localResult + ]; } public function buildConnectionParams(array $params): array @@ -244,12 +247,29 @@ class LocalToolsTable extends AppTable return $local_tools; } - public function sendEncodedConnection($remoteCerebrate, $connectorName, $encodedConnection) + public function sendEncodedConnection($params, $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 + $this->Broods = \Cake\ORM\TableRegistry::getTableLocator()->get('Broods'); + try { + $response = $this->Broods->sendLocalToolConnectionRequest($params, $encodedConnection); + $jsonReply = $response->getJson(); + if (empty($jsonReply['success'])) { + $this->handleMessageNotCreated($response); + } + } catch (NotFoundException $e) { + return $this->handleSendingFailed($response); + } + return $jsonReply; + } + + public function handleSendingFailed($response) + { + // debug('sending failed. Modify state and add entry in outbox'); + throw new NotFoundException(__('sending failed. Modify state and add entry in outbox')); + } + + public function handleMessageNotCreated($response) + { + // debug('Saving message failed. Modify state and add entry in outbox'); } } diff --git a/webroot/js/bootstrap-helper.js b/webroot/js/bootstrap-helper.js index 8f6108c..de52527 100644 --- a/webroot/js/bootstrap-helper.js +++ b/webroot/js/bootstrap-helper.js @@ -997,13 +997,17 @@ class FormValidationHelper { } else { $messageNode.addClass('invalid-feedback') } - const hasMultipleErrors = Object.keys(errors).length > 1 - for (const [ruleName, error] of Object.entries(errors)) { - if (hasMultipleErrors) { - $messageNode.append($('
  • ').text(error)) - } else { - $messageNode.text(error) + if (typeof errors === 'object') { + const hasMultipleErrors = Object.keys(errors).length > 1 + for (const [ruleName, error] of Object.entries(errors)) { + if (hasMultipleErrors) { + $messageNode.append($('
  • ').text(error)) + } else { + $messageNode.text(error) + } } + } else { + $messageNode.text(errors) } return $messageNode }