diff --git a/application/core/FHCAPI_Controller.php b/application/core/FHCAPI_Controller.php new file mode 100644 index 000000000..fea4e76f0 --- /dev/null +++ b/application/core/FHCAPI_Controller.php @@ -0,0 +1,224 @@ +config->set_item('error_views_path', VIEWPATH.'errors'.DIRECTORY_SEPARATOR.'json'.DIRECTORY_SEPARATOR); + + global $g_result; + $g_result = $this; + + ob_start(function ($content) { + $http_response_code = http_response_code(); + // NOTE(chris): For security reasons 404 will be displayed the same everywhere + if ($http_response_code == REST_Controller::HTTP_NOT_FOUND) + return $content; + + header('Content-Type: application/json; charset=utf-8'); + + if (!isset($this->returnObj['meta']) || !isset($this->returnObj['meta']['status'])) { + switch ($http_response_code) { + case 200: + $this->setStatus(self::STATUS_SUCCESS); + break; + case 400: + $this->setStatus(self::STATUS_FAIL); + break; + default: + $this->setStatus(self::STATUS_ERROR); + break; + } + } + + #$this->returnObj['test'] = implode('/n', headers_list()); + + return json_encode($this->returnObj); + }); + + // Load libraries + $this->load->library('AuthLib'); + $this->load->library('PermissionLib'); + + // Checks if the caller is allowed to access to this content + $this->_isAllowed($requiredPermissions); + + // For JSON Requests (as opposed to multipart/form-data) get the $_POST variable from the input stream instead + if ($this->input->get_request_header('Content-Type', true) == 'application/json') + $_POST = json_decode($this->security->xss_clean($this->input->raw_input_stream), true); + } + + + // --------------------------------------------------------------- + // Handle Output object + // --------------------------------------------------------------- + + /** + * @param array $data + * @param string (optional) $type + * @return void + */ + public function addError($data, $type = null) + { + if (!isset($this->returnObj['errors'])) + $this->returnObj['errors'] = []; + + $error = []; + + if (is_array($data)) { + if ($type == self::ERROR_TYPE_VALIDATION) + $error['messages'] = $data; + else + $error = $data; + } else { + $error['message'] = $data; + } + + if ($type) + $error['type'] = $type; + + $this->returnObj['errors'][] = $error; + } + + /** + * @param mixed $data + * @return void + */ + public function setData($data) + { + $this->returnObj['data'] = $data; + } + + /** + * @param string $status + * @return void + */ + public function setStatus($status) + { + if (!isset($this->returnObj['meta'])) + $this->returnObj['meta'] = []; + $this->returnObj['meta']['status'] = $status; + } + + + // --------------------------------------------------------------- + // Handle Output object - Shortcut functions + // --------------------------------------------------------------- + + /** + * @param array $errors + * @return void + */ + protected function terminateWithValidationErrors($errors) + { + $this->output->set_status_header(REST_Controller::HTTP_BAD_REQUEST); + $this->addError($errors, self::ERROR_TYPE_VALIDATION); + $this->setStatus(self::STATUS_FAIL); + exit(EXIT_ERROR); + } + + /** + * @param mixed $data (optional) + * @return void + */ + protected function terminateWithSuccess($data = null) + { + $this->setData($data); + $this->setStatus(self::STATUS_SUCCESS); + exit; + } + + // TODO(chris): complete list + + + // --------------------------------------------------------------- + // Security + // --------------------------------------------------------------- + + /** + * Checks if the caller is allowed to access to this content with the given permissions + * If it is not allowed will set the HTTP header with code 401 + * Wrapper for permissionlib->isEntitled + * + * @param array $requiredPermissions + * @return void + */ + protected function _isAllowed($requiredPermissions) + { + // Checks if this user is entitled to access to this content + if (!$this->permissionlib->isEntitled($requiredPermissions, $this->router->method)) + { + $this->output->set_status_header(isLogged() ? REST_Controller::HTTP_FORBIDDEN : REST_Controller::HTTP_UNAUTHORIZED); + + $this->addError([ + 'message' => 'You are not allowed to access to this content', + 'controller' => $this->router->class, + 'method' => $this->router->method, + 'required_permissions' => $this->_rpsToString($requiredPermissions, $this->router->method) + ]); + exit; // immediately terminate the execution + } + } + + /** + * Converts an array of permissions to a string that contains them as a comma separated list + * Ex: ", , " + * + * @param array $requiredPermissions + * @param string $method + * @return void + */ + protected function _rpsToString($requiredPermissions, $method) + { + if (!isset($requiredPermissions[$method])) + return ''; + + if (!is_array($requiredPermissions[$method])) + return $requiredPermissions[$method]; + + return implode(', ', $requiredPermissions[$method]); + } +} diff --git a/application/views/errors/json/html/error_404.php b/application/views/errors/json/html/error_404.php new file mode 100644 index 000000000..0caade2b1 --- /dev/null +++ b/application/views/errors/json/html/error_404.php @@ -0,0 +1,65 @@ + + + + +404 Page Not Found + + + +
+

+ +
+ + diff --git a/application/views/errors/json/html/error_db.php b/application/views/errors/json/html/error_db.php new file mode 100644 index 000000000..dce6a7572 --- /dev/null +++ b/application/views/errors/json/html/error_db.php @@ -0,0 +1,49 @@ +

', $msg); + +$msgs = []; + +$error = [ + 'heading' => $heading +]; + +/** NOTE(chris): extract Error Number and SQL + * @see: DB_driver.php:692 + */ +if (substr(current($msg), 0, 14) == 'Error Number: ') { + $code = substr(array_shift($msg), 14); + if ($code) + $error['code'] = (int)$code; + $msgs[] = array_shift($msg); + $error['sql'] = array_shift($msg); +} + +/** NOTE(chris): extract Line Number and Filename + * @see: DB_driver.php:1782 + * @see: DB_driver.php:1783 + */ +if (count($msg) >= 2) { + if (substr(end($msg), 0, 13) == 'Line Number: ' && substr(prev($msg), 0, 10) == 'Filename: ') { + $error['line'] = (int)substr(array_pop($msg), 13); + $error['filename'] = substr(array_pop($msg), 10); + } +} + +foreach ($msg as $m) + $msgs[] = $m; + + +if (count($msgs) == 1) + $error['message'] = current($msgs); +else + $error['messages'] = $msgs; + +$g_result->addError($error, FHCAPI_Controller::ERROR_TYPE_DB); +$g_result->setStatus(FHCAPI_Controller::STATUS_ERROR); diff --git a/application/views/errors/json/html/error_exception.php b/application/views/errors/json/html/error_exception.php new file mode 100644 index 000000000..7984bd13e --- /dev/null +++ b/application/views/errors/json/html/error_exception.php @@ -0,0 +1,27 @@ + $message, + 'class' => get_class($exception), + 'filename' => $exception->getFile(), + 'line' => $exception->getLine() +]; + +if (defined('SHOW_DEBUG_BACKTRACE') && SHOW_DEBUG_BACKTRACE === true) { + $error['backtrace'] = []; + foreach (debug_backtrace() as $err) { + if (isset($err['file']) && strpos($err['file'], realpath(BASEPATH)) !== 0) { + $error['backtrace'][] = [ + 'file' => $err['file'], + 'line' => $err['line'], + 'function' => $err['function'] + ]; + } + } +} + +$g_result->addError($error, FHCAPI_Controller::ERROR_TYPE_EXCEPTION); +$g_result->setStatus(FHCAPI_Controller::STATUS_ERROR); diff --git a/application/views/errors/json/html/error_general.php b/application/views/errors/json/html/error_general.php new file mode 100644 index 000000000..e69494463 --- /dev/null +++ b/application/views/errors/json/html/error_general.php @@ -0,0 +1,20 @@ +

', $msg); + +$error = [ + 'heading' => $heading +]; +if (count($msg) == 1) + $error['message'] = current($msg); +else + $error['messages'] = $msg; + +$g_result->addError($error, FHCAPI_Controller::ERROR_TYPE_GENERAL); +$g_result->setStatus(FHCAPI_Controller::STATUS_ERROR); diff --git a/application/views/errors/json/html/error_php.php b/application/views/errors/json/html/error_php.php new file mode 100644 index 000000000..91f2abf3c --- /dev/null +++ b/application/views/errors/json/html/error_php.php @@ -0,0 +1,31 @@ + $message, + 'severity' => $severity, + 'filename' => $filepath, + 'line' => $line +]; + +if (defined('SHOW_DEBUG_BACKTRACE') && SHOW_DEBUG_BACKTRACE === true) { + $error['backtrace'] = []; + foreach (debug_backtrace() as $err) { + if (isset($err['file']) && strpos($err['file'], realpath(BASEPATH)) !== 0) { + $error['backtrace'][] = [ + 'file' => $err['file'], + 'line' => $err['line'], + 'function' => $err['function'] + ]; + } + } +} + +// TODO(chris): change type with severity +$g_result->addError($error, 'php'); + +if (((E_ERROR | E_PARSE | E_COMPILE_ERROR | E_CORE_ERROR | E_USER_ERROR) & $severity) === $severity) { + $g_result->setStatus('error'); +} diff --git a/public/css/Fhc.css b/public/css/Fhc.css index 9342be5a3..c6618b923 100644 --- a/public/css/Fhc.css +++ b/public/css/Fhc.css @@ -1,6 +1,45 @@ -.text-prewrap { +.fhc-header { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: space-between; + align-items: baseline; + margin-bottom: 3rem; +} +.fhc-header > h1:first-child { + font-size: calc(1.325rem + .9vw); +} +.fhc-header > :first-child > small { + color: var(--bs-secondary); + font-size: .65em; + padding-inline-start: 1em; +} +.fhc-header:after { + background-color: var(--bs-gray-300); + content: ""; + display: block; + height: 1px; + width: 100%; +} + +.fhc-alert.p-toast-center { + width: 35rem; + max-width: 100vw; +} +.fhc-alert.p-toast-top-right { + max-width: calc(100vw - 40px); +} +.fhc-alert.p-toast-top-right .p-toast-detail, +.fhc-alert.p-toast-center .p-toast-message-text .card { + white-space: pre-wrap; +} + +.text-preline { white-space: pre-line; } +.text-prewrap { + white-space: pre-wrap; +} .btn-p-0 { padding: 0 .375rem; diff --git a/public/js/components/Form/Form.js b/public/js/components/Form/Form.js index 2257eb289..9a729b88f 100644 --- a/public/js/components/Form/Form.js +++ b/public/js/components/Form/Form.js @@ -9,7 +9,8 @@ export default { $registerToForm: component => { if (this.inputs.indexOf(component) < 0) this.inputs.push(component); - } + }, + $clearValidationForName: this.clearValidationForName }; }, props: { @@ -40,9 +41,33 @@ export default { return a; }, {}); + }, + factory() { + const factory = Object.create(Object.getPrototypeOf(this.$fhcApi.factory), Object.getOwnPropertyDescriptors(this.$fhcApi.factory)); + factory.$fhcApi = { + get: this.get, + post: this.post + }; + return factory; } }, methods: { + get(...args) { + if (typeof args[0] == 'object' && args[0].clearValidation && args[0].setFeedback) + args[0] = this; + else + args.unshift(this); + + return this.$fhcApi.get(...args); + }, + post(...args) { + if (typeof args[0] == 'object' && args[0].clearValidation && args[0].setFeedback) + args[0] = this; + else + args.unshift(this); + + return this.$fhcApi.post(...args); + }, _sendFeedbackToInput(inputs, feedback, valid) { if (inputs.length) { inputs.forEach(input => input.setFeedback(valid, feedback)); @@ -86,48 +111,9 @@ export default { clearValidation() { this.inputs.forEach(input => input.clearValidation()); }, - send(promise) { - return new Promise((resolve, reject) => { - promise.then(result => { - if (result?.status == 200 && result.data) { - if (typeof result.data !== 'object' || !result.data.hasOwnProperty('retval')) - // TODO(chris): IMPLEMENT! Error in API - return reject(result); - if (result.data.error) - // TODO(chris): IMPLEMENT! Error in API - return reject(result); - const data = result.data.retval; - // TODO(chris): check for something better/add new standardized return value - if (result.data.code == 1) - this.setFeedback(true, data); - return resolve(data); - } - // TODO(chris): IMPLEMENT! Wrong result object - reject(result); - }).catch(result => { - if (result?.response?.status == 400 && result.response.data) { - if (typeof result.response.data !== 'object' || !result.response.data.hasOwnProperty('retval')) - // TODO(chris): IMPLEMENT! Error in API - return reject(result); - this.clearValidation(); - const remaining = this.setFeedback( - false, - result.response.data.retval - ); - if (remaining) { - result.response.data.retval = remaining; - return reject(result); - } - } else if (result?.response?.status == 500) { - if (this.$fhcAlert) - this.$fhcAlert.handleSystemError(result); - else - return reject(result); - } else { - return reject(result); - } - }); - }); + clearValidationForName(name) { + (this.sortedInputs[name.split('.')[0] + name.split('.').slice(1).map(p => `[${p}]`).join("")] || this.sortedInputs['_default'] || []) + .forEach(input => input.clearValidation()); } }, template: ` diff --git a/public/js/components/Form/Input.js b/public/js/components/Form/Input.js index 4fc565dbb..4103c75cf 100644 --- a/public/js/components/Form/Input.js +++ b/public/js/components/Form/Input.js @@ -7,9 +7,16 @@ export default { components: { FhcFragment }, - inject: [ - '$registerToForm' - ], + inject: { + registerToForm: { + from: '$registerToForm', + default: null + }, + clearValidationForName: { + from: '$clearValidationForName', + default: null + } + }, props: { bsFeedback: Boolean, noAutoClass: Boolean, @@ -72,8 +79,6 @@ export default { }, tag() { switch (this.lcType) { - case 'textarea+': - return 'textarea'; case 'textarea': case 'select': return this.lcType; @@ -126,7 +131,6 @@ export default { case 'number': case 'password': case 'textarea': - case 'textarea+': if (!c.includes('form-control')) classes.push('form-control'); break; @@ -180,12 +184,30 @@ export default { this.valid = undefined; this.feedback = []; }, + clearValidationForThisName() { + if (this.valid === undefined) + return; + if (this.clearValidationForName && this.name) + this.clearValidationForName(this.name); + else + this.clearValidation(); + }, setFeedback(valid, feedback) { if (!feedback) feedback = []; if (!Array.isArray(feedback)) feedback = [feedback]; this.valid = valid; + // NOTE(chris): On a list of radios/checkboxes only add the feedback message to the last item + if (this.name && (this.lcType == 'radio' || this.lcType == 'checkbox')) { + let n = this.$el.nextSibling; + for (; n; n = n.nextSibling) + if (n.nodeType == 1 + && n.__vueParentComponent?.ctx?.lcType == this.lcType + && n.__vueParentComponent?.ctx?.name == this.name + ) + return; + } this.feedback = feedback; }, _loadComponents() { @@ -207,15 +229,15 @@ export default { this._loadComponents(); }, mounted() { - if (this.$registerToForm) - this.$registerToForm(this); + if (this.registerToForm) + this.registerToForm(this); }, template: ` - - - + + @@ -245,7 +267,7 @@ export default { :input-props="{name}" :class="validationClass" :input-class="[...Object.entries({'form-control': !noAutoClass, 'is-valid': valid === true, 'is-invalid': valid === false}).reduce((a,[k,v]) => {if(v) a.push(k);return a}, []), ...($attrs['input-class'] ? $attrs['input-class'].split(' ') : [])].join(' ')" - @update:model-value="clearValidation" + @update:model-value="clearValidationForThisName" > @@ -261,7 +283,7 @@ export default { :class="validationClass" :input-class="validationClass" :no-list="inputGroup" - @update:model-value="clearValidation" + @update:model-value="clearValidationForThisName" > @@ -275,12 +297,11 @@ export default { :id="idCmp" :name="name" :class="validationClass" - @update:model-value="clearValidation" + @update:model-value="clearValidationForThisName" > -

{{ modelValueCmp?.length || 0 }} / {{ $attrs.maxlength }}