diff --git a/src/Controller/InstanceController.php b/src/Controller/InstanceController.php index 336c413..074e59a 100644 --- a/src/Controller/InstanceController.php +++ b/src/Controller/InstanceController.php @@ -105,8 +105,9 @@ class InstanceController extends AppController public function settings() { $this->Settings = $this->getTableLocator()->get('Settings'); - $all = $this->Settings->getSettings(); + $all = $this->Settings->getSettings(true); $this->set('settingsProvider', $all['settingsProvider']); $this->set('settings', $all['settings']); + $this->set('notices', $all['notices']); } } diff --git a/src/Model/Table/SettingsProviderTable.php b/src/Model/Table/SettingsProviderTable.php index 8040482..ccc3eba 100644 --- a/src/Model/Table/SettingsProviderTable.php +++ b/src/Model/Table/SettingsProviderTable.php @@ -7,12 +7,20 @@ use Cake\Validation\Validator; class SettingsProviderTable extends AppTable { private $settingsConfiguration = []; + private $error_critical = '', + $error_warning = '', + $error_info = ''; + private $severities = ['info', 'warning', 'critical']; public function initialize(array $config): void { parent::initialize($config); $this->settingsConfiguration = $this->generateSettingsConfiguration(); $this->setTable(false); + $this->error_critical = __('Cerebrate will not operate correctly or will be unsecure until these issues are resolved.'); + $this->error_warning = __('Some of the features of Cerebrate cannot be utilised until these issues are resolved.'); + $this->error_info = __('There are some optional tweaks that could be done to improve the looks of your Cerebrate instance.'); + $this->settingValidator = new SettingValidator(); } /** @@ -28,26 +36,101 @@ class SettingsProviderTable extends AppTable } return $settingConf; } - - private function mergeSettingsIntoSettingConfiguration($settingConf, $settings) + + /** + * mergeSettingsIntoSettingConfiguration Inject the provided settings into the configuration while performing depencency and validation checks + * + * @param array $settingConf the setting configuration to have the setting injected into + * @param array $settings the settings + * @return void + */ + private function mergeSettingsIntoSettingConfiguration(array $settingConf, array $settings): array { foreach ($settingConf as $key => $value) { if ($this->isLeaf($value)) { if (isset($settings[$key])) { $settingConf[$key]['value'] = $settings[$key]; } + $settingConf[$key] = $this->evaluateLeaf($settingConf[$key], $settingConf); } else { $settingConf[$key] = $this->mergeSettingsIntoSettingConfiguration($value, $settings); } } return $settingConf; } + + /** + * getNoticesFromSettingsConfiguration Summarize the validation errors + * + * @param array $settingsProvider the setting configuration having setting value assigned + * @return void + */ + public function getNoticesFromSettingsConfiguration(array $settingsProvider): array + { + $notices = []; + foreach ($settingsProvider as $key => $value) { + if ($this->isLeaf($value)) { + if (!empty($value['error'])) { + if (empty($notices[$value['severity']])) { + $notices[$value['severity']] = []; + } + $notices[$value['severity']][] = $key; + } + } else { + $notices = array_merge($notices, $this->getNoticesFromSettingsConfiguration($value)); + } + } + return $notices; + } private function isLeaf($setting) { return !empty($setting['name']) && !empty($setting['type']); } + private function evaluateLeaf($setting, $settingSection) + { + $skipValidation = false; + if (isset($setting['dependsOn'])) { + $parentSetting = null; + foreach ($settingSection as $settingSectionName => $settingSectionConfig) { + if ($settingSectionName == $setting['dependsOn']) { + $parentSetting = $settingSectionConfig; + } + } + if (!is_null($parentSetting)) { + $parentSetting = $this->evaluateLeaf($parentSetting, $settingSection); + $skipValidation = $parentSetting['error'] === true || empty($parentSetting['value']); + } + } + if (!$skipValidation) { + if (isset($setting['test'])) { + $error = false; + $setting['value'] = $setting['value'] ?? ''; + if (is_callable($setting['test'])) { // Validate with anonymous function + $error = $setting['test']($setting['value'], $setting); + } else if (method_exists($this->settingValidator, $setting['test'])) { // Validate with function defined in settingValidator class + $error = $this->settingValidator->{$setting['test']}($setting['value'], $setting); + } else { + $validator = new Validator(); + if (method_exists($validator, $setting['test'])) { // Validate with cake's validator function + $validator->{$setting['test']}; + $error = $validator->validate($setting['value']); + } + } + if ($error !== true) { + $setting['severity'] = $setting['severity'] ?? 'warning'; + if (!in_array($setting['severity'], $this->severities)) { + $setting['severity'] = 'warning'; + } + $setting['errorMessage'] = $error; + } + $setting['error'] = $error !== false ? true : false; + } + } + return $setting; + } + /** * Support up to 3 level: * Application -> Network -> Proxy -> Proxy.URL @@ -60,17 +143,17 @@ class SettingsProviderTable extends AppTable 'Application' => [ 'General' => [ 'Essentials' => [ - 'baseurl' => [ + 'app.baseurl' => [ 'description' => __('The base url of the application (in the format https://www.mymispinstance.com or https://myserver.com/misp). Several features depend on this setting being correctly set to function.'), - 'errorMessage' => __('The currently set baseurl does not match the URL through which you have accessed the page. Disregard this if you are accessing the page via an alternate URL (for example via IP address).'), + 'severity' => 'critical', 'default' => '', 'name' => __('Base URL'), 'test' => 'testBaseURL', 'type' => 'string', ], - 'uuid' => [ + 'app.uuid' => [ 'description' => __('The Cerebrate instance UUID. This UUID is used to identify this instance.'), - 'errorMessage' => __('No valid UUID set'), + 'severity' => 'critical', 'default' => '', 'name' => 'UUID', 'test' => 'testUuid', @@ -81,8 +164,11 @@ class SettingsProviderTable extends AppTable 'to-del' => [ 'description' => 'to del', 'errorMessage' => 'to del', - 'default' => '', + 'default' => 'A-default-value', 'name' => 'To DEL', + 'test' => function ($value) { + return empty($value) ? __('Oh not! it\'s not valid!') : ''; + }, 'type' => 'string' ], 'to-del2' => [ @@ -110,32 +196,34 @@ class SettingsProviderTable extends AppTable ], 'Network' => [ 'Proxy' => [ - 'host' => [ + 'proxy.host' => [ 'description' => __('The hostname of an HTTP proxy for outgoing sync requests. Leave empty to not use a proxy.'), 'default' => '', 'name' => __('Host'), 'test' => 'testHostname', 'type' => 'string', ], - 'port' => [ + 'proxy.port' => [ 'description' => __('The TCP port for the HTTP proxy.'), 'default' => '', 'name' => __('Port'), 'test' => 'testForRangeXY', 'type' => 'integer', ], - 'user' => [ + 'proxy.user' => [ 'description' => __('The authentication username for the HTTP proxy.'), - 'default' => '', + 'default' => 'admin', 'name' => __('User'), - 'test' => 'testForEmpty', + 'test' => 'testEmptyBecomesDefault', + 'dependsOn' => 'proxy.host', 'type' => 'string', ], - 'password' => [ + 'proxy.password' => [ 'description' => __('The authentication password for the HTTP proxy.'), 'default' => '', 'name' => __('Password'), - 'test' => 'testForEmpty', + 'test' => 'testEmptyBecomesDefault', + 'dependsOn' => 'proxy.host', 'type' => 'string', ], ], @@ -158,20 +246,20 @@ class SettingsProviderTable extends AppTable 'description' => __('The authentication username for the HTTP proxy.'), 'default' => '', 'name' => __('User'), - 'test' => 'testForEmpty', + 'test' => 'testEmptyBecomesDefault', 'type' => 'string', ], 'password' => [ 'description' => __('The authentication password for the HTTP proxy.'), 'default' => '', 'name' => __('Password'), - 'test' => 'testForEmpty', + 'test' => 'testEmptyBecomesDefault', 'type' => 'string', ], ], ], 'UI' => [ - 'dark' => [ + 'app.ui.dark' => [ 'description' => __('Enable the dark theme of the application'), 'default' => false, 'name' => __('Dark theme'), @@ -179,11 +267,48 @@ class SettingsProviderTable extends AppTable ], ], ], - 'Features' => [ - ], 'Security' => [ ], + 'Features' => [ + ], ]; } } +class SettingValidator +{ + + public function testEmptyBecomesDefault($value, $setting) + { + if (!empty($value)) { + return true; + } else if (!empty($setting['default'])) { + return __('Setting is not set, fallback to default value: {0}', $setting['default']); + } else { + return __('Cannot be empty'); + } + } + + public function testForEmpty($value, $setting) + { + return !empty($value) ? true : __('Cannot be empty'); + } + + public function testBaseURL($value, $setting) + { + if (empty($value)) { + return __('Cannot be empty'); + } + if (!empty($value) && !preg_match('/^http(s)?:\/\//i', $value)) { + return __('Invalid URL, please make sure that the protocol is set.'); + } + return true; + } + + public function testUuid($value, $setting) { + if (empty($value) || !preg_match('/^\{?[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}\}?$/', $value)) { + return __('Invalid UUID.'); + } + return true; + } +} \ No newline at end of file diff --git a/src/Model/Table/SettingsTable.php b/src/Model/Table/SettingsTable.php index beb6a69..ac79cc7 100644 --- a/src/Model/Table/SettingsTable.php +++ b/src/Model/Table/SettingsTable.php @@ -16,13 +16,19 @@ class SettingsTable extends AppTable $this->SettingsProvider = TableRegistry::getTableLocator()->get('SettingsProvider'); } - public function getSettings(): array + public function getSettings($full=false): array { $settings = Configure::read()['Cerebrate']; - $settingsProvider = $this->SettingsProvider->getSettingsConfiguration($settings); - return [ - 'settings' => $settings, - 'settingsProvider' => $settingsProvider - ]; + if (empty($full)) { + return $settings; + } else { + $settingsProvider = $this->SettingsProvider->getSettingsConfiguration($settings); + $notices = $this->SettingsProvider->getNoticesFromSettingsConfiguration($settingsProvider, $settings); + return [ + 'settings' => $settings, + 'settingsProvider' => $settingsProvider, + 'notices' => $notices, + ]; + } } } diff --git a/templates/Instance/settings.php b/templates/Instance/settings.php index 309694f..daea2bf 100644 --- a/templates/Instance/settings.php +++ b/templates/Instance/settings.php @@ -1,9 +1,73 @@ __('Your Cerebrate instance requires immediate attention.'), + 'warning' => __('Issues found, it is recommended that you resolve them.'), + 'info' => __('There are some optional settings that are incorrect or not set.'), +]; +$noticeDescriptionPerLevel = [ + 'critical' => __('Cerebrate will not operate correctly or will be unsecure until these issues are resolved.'), + 'warning' => __('Some of the features of Cerebrate cannot be utilised until these issues are resolved.'), + 'info' => __('There are some optional tweaks that could be done to improve the looks of your Cerebrate instance.'), +]; +$headingPerLevel = [ + 'critical' => __('Critical settings'), + 'warning' => __('Warning settings'), + 'info' => __('Info settings'), +]; + $settingTable = genLevel0($settingsProvider, $this); +$alertVariant = 'info'; +$alertBody = ''; +$skipHeading = false; +$tableItems = []; +foreach (array_keys($mainNoticeHeading) as $level) { + if(!empty($notices[$level])) { + $variant = $level == 'critical' ? 'danger' : $level; + if (!$skipHeading) { + $alertBody .= sprintf('
%s
', $mainNoticeHeading[$level]); + $alertVariant = $variant; + $skipHeading = true; + } + $tableItems[] = [ + 'severity' => $headingPerLevel[$level], + 'issues' => count($notices[$level]), + 'badge-variant' => $variant, + 'description' => $noticeDescriptionPerLevel[$level], + ]; + } +} +$alertBody .= $this->Bootstrap->table([ + 'small' => true, + 'striped' => false, + 'hover' => false, + 'borderless' => true, + 'bordered' => false, + 'tableClass' => 'mb-0' +], [ + 'fields' => [ + ['key' => 'severity', 'label' => __('Severity')], + ['key' => 'issues', 'label' => __('Issues'), 'formatter' => function($count, $row) { + return $this->Bootstrap->badge([ + 'variant' => $row['badge-variant'], + 'text' => $count + ]); + }], + ['key' => 'description', 'label' => __('Description')] + ], + 'items' => $tableItems, +]); +$settingNotice = $this->Bootstrap->alert([ + 'variant' => $alertVariant, + 'html' => $alertBody +]); ?>
+
+ +
@@ -28,7 +92,7 @@ function genLevel0($settingsProvider, $appView) 'card' => false, 'pills' => false, 'justify' => 'center', - 'content-class' => ['mt-2'], + 'content-class' => [''], 'data' => [ 'navs' => $level0, 'content' => $content0 @@ -100,10 +164,21 @@ function genLevel3($level2Name, $settingGroupName, $setting, $appView) function genSingleSetting($settingName, $setting, $appView) { + $dependsOnHtml = ''; + if (!empty($setting['dependsOn'])) { + $dependsOnHtml = $appView->Bootstrap->genNode('span', [ + ], $appView->Bootstrap->genNode('sup', [ + 'class' => [ + $appView->FontAwesome->getClass('info'), + 'ml-1', + ], + 'title' => __('This setting depends on the validity of: {0}', h($setting['dependsOn'])) + ])); + } $label = $appView->Bootstrap->genNode('label', [ 'class' => ['font-weight-bolder', 'mb-0'], 'for' => $settingName - ], h($setting['name'])); + ], h($setting['name']) . $dependsOnHtml); $description = ''; if (!empty($setting['description'])) { $description = $appView->Bootstrap->genNode('small', [ @@ -111,7 +186,16 @@ function genSingleSetting($settingName, $setting, $appView) 'id' => "{$settingName}Help" ], h($setting['description'])); } - $inputGroup = ''; + $error = ''; + if (!empty($setting['error'])) { + $textColor = ''; + if ($setting['severity'] != 'critical') { + $textColor = "text-{$setting['severity']}"; + } + $error = $appView->Bootstrap->genNode('div', [ + 'class' => ['d-block', 'invalid-feedback', $textColor], + ], h($setting['errorMessage'])); + } if (empty($setting['type'])) { $setting['type'] = 'string'; } @@ -131,19 +215,23 @@ function genSingleSetting($settingName, $setting, $appView) } $container = $appView->Bootstrap->genNode('div', [ 'class' => ['form-group', 'mb-2'] - ], implode('', [$label, $input, $description])); + ], implode('', [$label, $input, $description, $error])); return $container; } function genInputString($settingName, $setting, $appView) { + // debug($setting); return $appView->Bootstrap->genNode('input', [ 'class' => [ - 'form-control' + 'form-control', + (!empty($setting['error']) ? 'is-invalid' : ''), + (!empty($setting['error']) ? ($setting['severity'] != 'critical' ? "border-{$setting['severity']} warning" : '') : ''), ], 'type' => 'text', 'id' => $settingName, 'value' => isset($setting['value']) ? $setting['value'] : "", + 'placeholder' => $setting['default'] ?? '', 'aria-describedby' => "{$settingName}Help" ]); } diff --git a/webroot/css/bootstrap-additional.css b/webroot/css/bootstrap-additional.css index 50fb5a9..c01ce3b 100644 --- a/webroot/css/bootstrap-additional.css +++ b/webroot/css/bootstrap-additional.css @@ -109,31 +109,35 @@ div.progress-timeline .progress-line.progress-inactive { opacity: 0.5; } -.bd-callout { +.callout { border: 1px solid #eee; border-left-color: rgb(238, 238, 238); border-left-width: 1px; border-left-width: .25rem; border-radius: .25rem; } -.bd-callout-primary { +.callout-primary { border-left-color: var(--primary); } -.bd-callout-info { +.callout-info { border-left-color: var(--info); } -.bd-callout-success { +.callout-success { border-left-color: var(--success); } -.bd-callout-warning { +.callout-warning { border-left-color: var(--warning); } -.bd-callout-danger { +.callout-danger { border-left-color: var(--danger); } -.bd-callout-dark { +.callout-dark { border-left-color: var(--dark); } -.bd-callout-light { +.callout-light { border-left-color: var(--light); +} + +.form-control.is-invalid.warning { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23ffc107' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23ffc107' stroke='none'/%3e%3c/svg%3e") } \ No newline at end of file