diff --git a/application/core/FHCAPI_Controller.php b/application/core/FHCAPI_Controller.php new file mode 100644 index 000000000..e59740ded --- /dev/null +++ b/application/core/FHCAPI_Controller.php @@ -0,0 +1,255 @@ +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); + elseif (isset($_POST['_jsondata'])) { + $_POST = array_merge($_POST, json_decode($_POST['_jsondata'], true)); + unset($_POST['_jsondata']); + } + } + + + // --------------------------------------------------------------- + // Handle Output object + // --------------------------------------------------------------- + + /** + * @param array $data + * @param string $type (optional) + * @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; + } + + /** + * @param array $error + * @param string $type (optional) + * @return void + */ + protected function terminateWithError($error, $type = null) + { + $this->output->set_status_header(REST_Controller::HTTP_INTERNAL_SERVER_ERROR); + $this->addError($error, $type); + $this->setStatus(self::STATUS_ERROR); + exit; + } + + /** + * @param stdclass $result + * @param string $errortype + * @return void + */ + protected function checkForErrors($result, $errortype = self::ERROR_TYPE_GENERAL) + { + // TODO(chris): IMPLEMENT! + if (isError($result)) { + $this->terminateWithError(getError($result), $errortype); + } + return $result->retval; + } + + // 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 20bfd85d7..376206f6e 100644 --- a/public/css/Fhc.css +++ b/public/css/Fhc.css @@ -1,68 +1,100 @@ +.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-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-line; + white-space: pre-wrap; } .btn-p-0 { - padding: 0 .375rem; + padding: 0 .375rem; } .z-1 { - z-index: 1; + z-index: 1; } .input-group > .input-group-item { - position: relative; - flex: 1 1 auto; - width: 1%; - min-width: 0; + position: relative; + flex: 1 1 auto; + width: 1%; + min-width: 0; } .input-group > .input-group-item .form-control:focus, .input-group > .input-group-item .form-select:focus { - z-index: 3; - position: relative; + z-index: 3; + position: relative; } .input-group-lg > .input-group-item .form-control, .input-group-lg > .input-group-item .form-select { - padding: 0.5rem 1rem; - font-size: 1.25rem; - border-radius: 0.3rem; + padding: 0.5rem 1rem; + font-size: 1.25rem; + border-radius: 0.3rem; } .input-group-sm > .input-group-item .form-control, .input-group-sm > .input-group-item .form-select { - padding: 0.25rem 0.5rem; - font-size: 0.875rem; - border-radius: 0.2rem; + padding: 0.25rem 0.5rem; + font-size: 0.875rem; + border-radius: 0.2rem; } .input-group-lg > .input-group-item .form-select, .input-group-sm > .input-group-item .form-select { - padding-right: 3rem; + padding-right: 3rem; } .input-group:not(.has-validation) > .input-group-item:not(:last-child) .form-control, .input-group:not(.has-validation) > .input-group-item:not(:last-child) .form-select { - border-top-right-radius: 0; - border-bottom-right-radius: 0; + border-top-right-radius: 0; + border-bottom-right-radius: 0; } .input-group.has-validation > .input-group-item:nth-last-child(n+3) .form-control, .input-group.has-validation > .input-group-item:nth-last-child(n+3) .form-select { - border-top-right-radius: 0; - border-bottom-right-radius: 0; + border-top-right-radius: 0; + border-bottom-right-radius: 0; } .input-group > .input-group-item:not(:first-child) .form-control, .input-group > .input-group-item:not(:first-child) .form-select { - border-top-left-radius: 0; - border-bottom-left-radius: 0; + border-top-left-radius: 0; + border-bottom-left-radius: 0; } .form-control-color.is-invalid, .was-validated .form-control-color:invalid, .form-control-color.is-valid, .was-validated .form-control-color:valid { - padding-right: .375rem; - background-image: none; + padding-right: .375rem; + background-image: none; } diff --git a/public/css/Tabulator5.css b/public/css/Tabulator5.css index a79d0403b..a5bb4a34c 100644 --- a/public/css/Tabulator5.css +++ b/public/css/Tabulator5.css @@ -43,13 +43,14 @@ font-size: 1rem; } -.tabulator-row.tabulator-selectable:focus { - box-shadow: 0 0 0 .24rem rgba(13,110,253,.25); - z-index: 1; - outline: 0; - .tabulator-cell .btn { padding: 0 .5rem; max-height: 22px; min-width: 30px; } + +.tabulator-row.tabulator-selectable:focus { + box-shadow: 0 0 0 .24rem rgba(13,110,253,.25); + z-index: 1; + outline: 0; +} diff --git a/public/css/components/FilterComponent.css b/public/css/components/FilterComponent.css index cd0b33a48..50bfe561e 100644 --- a/public/css/components/FilterComponent.css +++ b/public/css/components/FilterComponent.css @@ -64,3 +64,12 @@ margin-top: 20px; } +.tabulator { + font-size: 1rem; +} +.tabulator-cell .btn { + padding: 0 .375rem; + font-size: .875rem; + border-radius: .2rem; +} + diff --git a/public/css/components/NavigationComponent.css b/public/css/components/NavigationComponent.css index 2d64d9cdc..429a131b5 100644 --- a/public/css/components/NavigationComponent.css +++ b/public/css/components/NavigationComponent.css @@ -82,6 +82,7 @@ /* * To be moved outside */ +.navbar.navbar-left-side ~ *, #content { position: inherit; margin: 0 0 0 250px; diff --git a/public/js/apps/Bismeldestichtag/Bismeldestichtag.js b/public/js/apps/Bismeldestichtag/Bismeldestichtag.js index 79b73a2e5..04275d76a 100644 --- a/public/js/apps/Bismeldestichtag/Bismeldestichtag.js +++ b/public/js/apps/Bismeldestichtag/Bismeldestichtag.js @@ -24,6 +24,8 @@ import {CoreRESTClient} from '../../RESTClient.js'; import {CoreFetchCmpt} from '../../components/Fetch.js'; import {BismeldestichtagAPIs} from './API.js'; +import Phrasen from '../../plugin/Phrasen.js'; + const bismeldestichtagApp = Vue.createApp({ data: function() { return { @@ -187,4 +189,4 @@ const bismeldestichtagApp = Vue.createApp({ } }); -bismeldestichtagApp.mount('#main'); +bismeldestichtagApp.use(Phrasen).mount('#main'); diff --git a/public/js/apps/LogsViewer/LogsViewer.js b/public/js/apps/LogsViewer/LogsViewer.js index 84798c3e1..8aefa1d67 100644 --- a/public/js/apps/LogsViewer/LogsViewer.js +++ b/public/js/apps/LogsViewer/LogsViewer.js @@ -21,6 +21,8 @@ import {LogsViewerTabulatorEventHandlers} from './TabulatorSetup.js'; import {CoreFilterCmpt} from '../../components/filter/Filter.js'; import {CoreNavigationCmpt} from '../../components/navigation/Navigation.js'; +import Phrasen from '../../plugin/Phrasen.js'; + const logsViewerApp = Vue.createApp({ data: function() { return { @@ -40,5 +42,5 @@ const logsViewerApp = Vue.createApp({ } }); -logsViewerApp.mount('#main'); +logsViewerApp.use(Phrasen).mount('#main'); diff --git a/public/js/apps/Studentenverwaltung.js b/public/js/apps/Studentenverwaltung.js index e1dacf792..dc3e7522b 100644 --- a/public/js/apps/Studentenverwaltung.js +++ b/public/js/apps/Studentenverwaltung.js @@ -18,8 +18,8 @@ import FhcStudentenverwaltung from "../components/Stv/Studentenverwaltung.js"; import fhcapifactory from "./api/fhcapifactory.js"; -import FhcAlert from "../plugin/FhcAlert.js"; -import FhcPhrasen from "../plugin/Phrasen.js"; +import FhcApi from "../plugin/FhcApi.js"; +import Phrasen from "../plugin/Phrasen.js"; //import PrimeVue form "../../../../index.ci.php/public/js/components/primevue/config/config.esm.min.js"); //const PrimeVue = await import(FHC_JS_DATA_STORAGE_OBJECT.app_root + FHC_JS_DATA_STORAGE_OBJECT.ci_router + "/public/js/components/primevue/config/config.esm.min.js").default; @@ -47,7 +47,7 @@ import(FHC_JS_DATA_STORAGE_OBJECT.app_root + FHC_JS_DATA_STORAGE_OBJECT.ci_route overlay: 1100 } }) - .use(FhcAlert) - .use(FhcPhrasen) + .use(FhcApi) + .use(Phrasen) .mount('#main'); }); diff --git a/public/js/components/Form/Form.js b/public/js/components/Form/Form.js index 2257eb289..fc5586355 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,52 +111,13 @@ 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: ` ` -} \ No newline at end of file +} diff --git a/public/js/components/Form/Input.js b/public/js/components/Form/Input.js index 1fc318523..e36658160 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, @@ -17,12 +24,14 @@ export default { inputGroup: Boolean, type: String, name: String, - containerClass: [String, Array, Object] + containerClass: [String, Array, Object], + label: String }, data() { return { valid: undefined, - feedback: [] + feedback: [], + modelValueDummy: undefined } }, computed: { @@ -31,8 +40,9 @@ export default { return true; if (this.containerClass) return true; - if (this.autoContainerClass) - return true; + for (const prop in this.autoContainerClass) + if (Object.hasOwn(this.autoContainerClass, prop)) + return true; return false; }, acc() { @@ -111,13 +121,19 @@ export default { if (!c.includes('form-check-input') && !c.includes('btn-check')) classes.push('form-check-input'); break; + case 'color': + if (!c.includes('form-control-color')) + classes.push('form-control-color'); + if (!c.includes('form-control')) + classes.push('form-control'); + break; case 'autocomplete': case 'datepicker': classes.push('p-0'); classes.push('border-0'); - case 'color': - if (!c.includes('form-control-color')) - classes.push('form-control-color'); + if (!c.includes('form-control')) + classes.push('form-control'); + break; case 'text': case 'number': case 'password': @@ -145,9 +161,13 @@ export default { }, modelValueCmp: { get() { + if (this.$attrs.modelValue === undefined) + return this.modelValueDummy; return this.$attrs.modelValue; }, set(v) { + if (this.$attrs.modelValue === undefined) + this.modelValueDummy = v; this.$emit('update:modelValue', v); } }, @@ -155,7 +175,7 @@ export default { let uuid = this.$attrs.id; if (this.lcType == 'datepicker') uuid = this.$attrs.uid; - if (!uuid && this.$attrs.label) + if (!uuid && this.label) uuid = 'fhc-form-input'; if (!uuid) return undefined; @@ -171,12 +191,26 @@ 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')) { + const selector = 'input[type="' + this.lcType + '"][name="' + this.name + '"]'; + if ([...this.$el.parentNode.querySelectorAll(selector)].pop() != this.$refs.input) + return; + } this.feedback = feedback; }, _loadComponents() { @@ -198,19 +232,20 @@ export default { this._loadComponents(); }, mounted() { - if (this.$registerToForm) - this.$registerToForm(this); + if (this.registerToForm) + this.registerToForm(this); }, template: ` - - - - + + - +