diff --git a/.gitignore b/.gitignore index b41078f88..223be8196 100644 --- a/.gitignore +++ b/.gitignore @@ -1,22 +1,22 @@ -vendor/ -tests/ -log/* -!log/.gitkeep -cache/* -!cache/.gitkeep -spool/* -!spool/.gitkeep -bin/* -!bin/.gitkeep -config/config.json -config/routing/* -!config/routing/.gitkeep +/vendor/ +/tests/ +/log/* +!/log/.gitkeep +/cache/* +!/cache/.gitkeep +/spool/* +!/spool/.gitkeep +/bin/* +!/bin/.gitkeep +/config/config.json +/config/routing/* +!/config/routing/.gitkeep +/public/* +!/public/index.php +!/public/console.php +!/public/assets composer.json composer.phar composer.lock .vscode .idea -public/* -!public/index.php -!public/console.php -!public/assets diff --git a/README.md b/README.md index 47b067b65..ab2abe74c 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,46 @@ +eQual is an open-source low-code framework, at once versatile, language-agnostic and web-oriented, designed to efficiently create and manage modern softwares that can adapt to any Application Logic. + +⭐ If you find eQual useful, nice, or simply relevant, please consider giving us a star on GitHub! Your support encourages us and will help making eQual the most powerful framework ever. + +🛠️ [Contributors welcome!](CONTRIBUTING.md) You want to contribute to a great open-source project? We need help to keep on 🚀, finishing 🚧, fixing 🐛, and make it 🎨 + [![Build Status](https://circleci.com/gh/equalframework/equal.svg?style=shield)](https://circleci.com/gh/equalframework/equal) +![Number of contributors](https://img.shields.io/github/contributors/equalframework/equal) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](https://github.com/equalframework/equal/pulls) [![License: LGPL v3](https://img.shields.io/badge/License-LGPL%20v3-blue.svg)](https://www.gnu.org/licenses/lgpl-3.0) -[![Maintainer](https://img.shields.io/badge/maintainer-cedricfrancoys-blue)](https://github.com/cedricfrancoys) -[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](https://github.com/cedricfrancoys/equal/pulls) -![eQual - Create great Apps, your way!](https://github.com/equalframework/equal/blob/master/public/assets/img/equal_logo.png?raw=true) -# Create great Apps, your way! +![GitHub commit activity](https://img.shields.io/github/commit-activity/m/equalframework/equal) + +

+ eQual - Create great Apps, your way! +

+ +# eQual - Create great Apps, your way! + +

+ eQual - Create great Apps, your way! +

+ + +eQual offers a native Low-Code approach, based on the definition of the application logic and components (rather than on code or the language used). + +Data is modeled via entities, to which a large part of the application logic is associated (workflow, roles, events, actions, policies), which are manipulated by the ORM, with which it is possible to interact using CQRS controllers. + +In turn, the Controllers can be invoked via an API. + +eQual offers tools that allow the visual consultation and editing of the different components, in turn in the form of relational diagrams, and entity, workflow, view, menu, and translation editors. + +It also has a rendering engine that allows views and menus to be assembled in order to define a complete application. + +This mechanism enables eQual to generate an application without writing a single line of code, providing both a user interface and an API that can be connected to any external service. -eQual is a versatile, language-agnostic and web-oriented framework, aiming to elegantly manage interactions between front-end Apps and Business Logic involved in modern Web Applications. ## Benefits **Rock Solid Security** Secure every API endpoint with User Management, Role-Based Access Controls, SSO Authentication, JWT, CORS, and OAuth. -**Server-Side Scripting** Implement custom logic on the request or response of any API endpoint or quickly build your own custom APIs with JavaScript V8, Node.js, or PHP. +**Server-Side Scripting** Implement custom logic on any endpoint to build your own custom API, and interact with it using your preferred programming language. -**Low-Code Instant APIs** Automatically generate a complete set of REST APIs with live documentation for any SQL or NoSQL database, file storage system, or external service. +**Low-Code Instant APIs** Automatically generate a complete set of ReST API endpoints with live documentation for any SQL or NoSQL database, file storage system, or external service. ## Example diff --git a/composer.json b/composer.json deleted file mode 100644 index 5dc9cadc2..000000000 --- a/composer.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "require": { - "swiftmailer/swiftmailer": "^6.2", - "phpoffice/phpspreadsheet": "^1.4", - "dompdf/dompdf": "^0.8.3" - }, - "require-dev": { - "phpunit/phpunit": "^9.5", - "phpunit/php-code-coverage": "^9.2", - "symplify/easy-coding-standard": "^11.1", - "nikic/php-parser": "4.15.4" - } -} \ No newline at end of file diff --git a/config/schema.json b/config/schema.json index 78ab19487..c4eea16ee 100644 --- a/config/schema.json +++ b/config/schema.json @@ -320,6 +320,12 @@ "description": "URL of the official website of the organisation.", "default": "https://equal.run" }, + "ORM_EVENTS_FORCE_ONUPDATE_AT_CREATION": { + "type": "boolean", + "description": "Flag for forcing ORM to invoke `onupdate` callback at object creation (i.e. when using `create()` method).", + "default": false, + "instant": true + }, "REST_API_URL": { "type": "string", "description": "The URL specific to the API path, if any. This URL should end with a slash ('/')", diff --git a/config/usages.json b/config/usages.json deleted file mode 100644 index fcbc06230..000000000 --- a/config/usages.json +++ /dev/null @@ -1,300 +0,0 @@ -{ - - "string" : { - "application" : { - "subusages" : { - "json" : {}, - "xml" : {}, - "yaml" : {} - } - }, - "text" : { - "subusages" : { - "plain" : { - "variations" : { - "short" : { - "length_free" : false, - "boundary" : false - }, - "small" : { - "length_free" : false, - "boundary" : false - }, - "medium": { - "length_free" : false, - "boundary" : false - }, - "long" : { - "length_free" : false, - "boundary" : false - } - } - }, - "xml" : {}, - "html" : {}, - "markdown" : {}, - "wiki" : {}, - "json" : {} - }, - "length_free" : true, - "boundary" : true - }, - "uri" : { - "subusages" : { - "url" : { - "variations" : { - "mailto": {}, - "payto" : {}, - "tel" : {}, - "http" : {}, - "ftp" : {} - } - }, - "urn" : { - "variations" : { - "iban": {}, - "isbn" : { - "length" : [ - 10, - 13 - ] - }, - "ean" : { - "length" : [ - 13 - ] - } - } - } - } - }, - "email" : {}, - "language" : { - "subusages" : { - "iso-639" : { - "length" : [ - 2, - 3 - ] - } - } - }, - "country" : { - "subusages" : { - "iso-3166" : { - "length" : [ - 2, - 3 - ] - } - } - }, - "password" : {}, - "coordinate" : { - "subusages" : { - "latitude" : { - "variations" : { - "decimal" : {}, - "dms" : {} - } - }, - "longitude" : { - "variations" : { - "decimal" : {}, - "dms" : {} - } - } - } - }, - "currency" : { - "subusages" : { - "iso-4217" : { - "variations" : { - "alpha" : {}, - "numeric" : {} - } - } - } - }, - "hash" : { - "subusages" : { - "md" : { - "variations" : { - "4" : { - "length" : [ - 32 - ] - }, - "5" : { - "length" : [ - 32 - ] - }, - "6" : { - "length" : [ - 64 - ] - } - } - }, - "sha" : { - "variations" : { - "1" : { - "length" : [ - 40 - ] - }, - "256" : { - "length" : [ - 64 - ] - }, - "512" : { - "length" : [ - 128 - ] - } - } - } - } - }, - "color" : { - "subusages" : { - "css" : {}, - "rgb" : {}, - "rgba" : {}, - "hexadecimal" : {} - } - }, - "orm" : { - "subusages" : { - "relationship" : { - "many2one" : {}, - "one2many" : {}, - "one2one" : {} - }, - "type" : { - "variations" : { - "float" : {}, - "string" : {}, - "binary" : {}, - "integer" : {}, - "time" : {}, - "datetime" : {}, - "date" : {} - } - }, - "entity" : {}, - "package" : {} - } - } - }, - "boolean" : { - "number" : { - "subusages" : { - "boolean" : {} - }, - "length_free" : true, - "boundary" : true - } - }, - "integer" : { - "number" : { - "subusages" : { - "natural" : {}, - "integer" : { - "variations" : { - "decimal" : {}, - "hexadecimal" : {}, - "octal" : {} - } - } - }, - "length_free" : true, - "boundary" : true - }, - "orm" : { - "subusages" : { - "object_id" : {} - } - } - }, - "float" : { - "amount" : { - "subusages" : { - "money" : { - "length_dim" : 2 - }, - "percent" : {}, - "rate" : {} - } - }, - "number" : { - "subusages" : { - "real" : { - "length_dim" : 2 - } - } - }, - "length_free" : true, - "boundary" : true - }, - "binary" : { - "image" : { - "subusages" : { - "jpeg" : {}, - "gif" : {}, - "png" : {}, - "tiff" : {}, - "wepb" : {} - } - }, - "application" : { - "subusages" : { - "pdf" : {}, - "zip" : {}, - "excel" : { - "variations" : { - "xlsx" : {}, - "xls" : {} - } - }, - "word" : { - "variations" : { - "doc" : {}, - "docx" : {} - } - }, - "powerpoint" : { - "ppt" : {}, - "pptx" : {} - } - } - }, - "audio" : { - "subusages" : { - "aac" : {}, - "webm" : {} - } - }, - "video" : { - "x-msvideo" : {}, - "webm" : {} - } - }, - "array" : { - "array" : { - "subusages" : { - "plain" : { - "boundary" : true - }, - "domain" : { - "boundary" : false - }, - "clause" : { - "boundary" : false - } - } - } - } -} \ No newline at end of file diff --git a/eq.lib.php b/eq.lib.php index fdf10dc7b..c738e22b0 100644 --- a/eq.lib.php +++ b/eq.lib.php @@ -127,12 +127,13 @@ */ define('EQ_R_CREATE', 1); define('EQ_R_READ', 2); - define('EQ_R_WRITE', 4); + define('EQ_R_UPDATE', 4); define('EQ_R_DELETE', 8); define('EQ_R_MANAGE', 16); define('EQ_R_ALL', 31); // equivalence map for constant names migration // #deprecated + define('EQ_R_WRITE', EQ_R_UPDATE); define('QN_R_CREATE', EQ_R_CREATE); define('QN_R_READ', EQ_R_READ); define('QN_R_WRITE', EQ_R_WRITE); @@ -303,6 +304,7 @@ function qn_debug_mode_name($mode) { } namespace config { use equal\services\Container; + use equal\error\Reporter; use equal\orm\Field; /* @@ -449,20 +451,20 @@ function constant($name, $default=null) { * Register a service by assigning an identifier (name) to a class (stored under `/lib`). * * This method can be invoked in local config files to register a custom service and/or to override any existing service, - * and uses the `QN_SERVICES_POOL` global array which is used by the root container. + * and uses the `EQ_SERVICES_POOL` global array which is used by the root container. * */ function register($name, $class=null) { - if(!isset($GLOBALS['QN_SERVICES_POOL'])) { - $GLOBALS['QN_SERVICES_POOL'] = []; + if(!isset($GLOBALS['EQ_SERVICES_POOL'])) { + $GLOBALS['EQ_SERVICES_POOL'] = []; } if(is_array($name)) { foreach($name as $service => $class) { - $GLOBALS['QN_SERVICES_POOL'][$service] = $class; + $GLOBALS['EQ_SERVICES_POOL'][$service] = $class; } } else { - $GLOBALS['QN_SERVICES_POOL'][$name] = $class; + $GLOBALS['EQ_SERVICES_POOL'][$name] = $class; } } @@ -640,10 +642,11 @@ public static function announce(array $announcement) { $container = Container::getInstance(); // retrieve required services /** - * @var \equal\php\Context $context - * @var \equal\error\Reporter $reporter + * @var \equal\php\Context $context + * @var \equal\auth\AuthenticationManager $auth + * @var \equal\error\Reporter $reporter */ - list($context, $reporter) = $container->get(['context', 'report']); + list($context, $auth, $reporter) = $container->get(['context', 'auth', 'report']); // fetch body and method from HTTP request $request = $context->httpRequest(); $body = (array) $request->body(); @@ -763,7 +766,6 @@ public static function announce(array $announcement) { if(isset($announcement['response']['cache-vary'])) { $vary = (array) $announcement['response']['cache-vary']; if(in_array('user', $vary)) { - list($auth) = $container->get(['auth']); $request_id .= '-'.$auth->userId(); } if(in_array('origin', $vary)) { @@ -842,19 +844,36 @@ public static function announce(array $announcement) { } } + if(!isset($announcement['access'])) { + $announcement['access'] = []; + } + + if( !isset($announcement['access']['visibility']) + || !in_array($announcement['access']['visibility'], ['public', 'protected', 'private']) + ) { + $announcement['access']['visibility'] = 'protected'; + } + // check access restrictions - if(isset($announcement['access']) && $method != 'OPTIONS') { - list($access, $auth) = $container->get(['access', 'auth']); - if(isset($announcement['access']['visibility'])) { - if($announcement['access']['visibility'] == 'private' && php_sapi_name() != 'cli') { - throw new \Exception('private_operation', EQ_ERROR_NOT_ALLOWED); - } - if($announcement['access']['visibility'] == 'protected') { - // #memo - regular rules will apply (non identified user shouldn't be granted unless DEFAULT_RIGHTS allow it) - if($auth->userId() <= 0) { - throw new \Exception('protected_operation', EQ_ERROR_NOT_ALLOWED); - } - } + if($announcement['access']['visibility'] != 'public' && php_sapi_name() != 'cli') { + // private is only allowed in CLI + if($announcement['access']['visibility'] == 'private') { + throw new \Exception('private_operation', EQ_ERROR_NOT_ALLOWED); + } + $user_id = $auth->userId(); + // user must be authenticated for protected + if($user_id <= 0) { + throw new \Exception('protected_operation', EQ_ERROR_NOT_ALLOWED); + } + // check Security Policies + /** @var \equal\access\AccessController */ + $access = $container->get('access'); + if(!$access->isRequestCompliant($user_id, $request->getHeaders()->getIpAddress())) { + Reporter::errorHandler(EQ_REPORT_SYSTEM, "AAA::".json_encode(['type' => 'policy', 'status' => 'denied'])); + throw new Exception("Request rejected by Security Policies", EQ_ERROR_NOT_ALLOWED); + } + else { + Reporter::errorHandler(EQ_REPORT_SYSTEM, "AAA::".json_encode(['type' => 'policy', 'status' => 'accepted', 'policy_id' => $access->getComplyingPolicyId()])); } if(isset($announcement['access']['users'])) { // disjunctions on users diff --git a/lib/equal/access/AccessController.class.php b/lib/equal/access/AccessController.class.php index 26dd021a2..d6abe5e2e 100644 --- a/lib/equal/access/AccessController.class.php +++ b/lib/equal/access/AccessController.class.php @@ -20,6 +20,8 @@ class AccessController extends Service { private $is_request_compliant; + private $complying_policy_id; + private $permissionsTable; private $groupsTable; @@ -33,6 +35,7 @@ class AccessController extends Service { */ protected function __construct(Container $container) { $this->is_request_compliant = false; + $this->complying_policy_id = 0; $this->permissionsTable = array(); $this->groupsTable = array(); $this->usersTable = array(); @@ -59,6 +62,88 @@ public function getUserGroups($user_id) { return $groups_ids; } + private function getRolesImplications($object_class) { + $map_roles = []; + + $roles = object_class::getRoles(); + + $getImpliedRoles = function($role) use (&$getImpliedRoles, &$roles, &$map_roles) { + if(isset($map_roles[$role])) { + return $map_roles[$role]; + } + + $implied_roles = []; + foreach($roles as $key => $value) { + if(isset($value['implied_by']) && in_array($role, (array) $value['implied_by'])) { + $implied_roles[] = $key; + $implied_roles = array_merge($implied_roles, $getImpliedRoles($key)); + } + } + + $map_roles[$role] = array_unique($implied_roles); + return $map_roles[$role]; + }; + + foreach(array_keys($roles) as $role) { + $getImpliedRoles($role, $roles, $map_roles); + } + + return $map_roles; + } + + public function getUserRoles($user_id, $object_class, $objects_ids) { + $roles = []; + + $def_roles = $object_class::getRoles(); + + if(count($def_roles)) { + $map_user_roles = []; + $orm = $this->container->get('orm'); + $roles_implications = $this->getRolesImplications($object_class); + $first = true; + + $getResultingRoles = function($role) use($roles_implications) { + $map_roles = [$role => true]; + if(isset($roles_implications[$role])) { + foreach((array) $roles_implications[$role] as $implied_role) { + $map_roles[$implied_role] = true; + } + } + return array_keys($map_roles); + }; + + foreach($objects_ids as $object_id) { + $assignments_ids = $orm->search('core\Assignment', [ ['user_id', '=', $user_id], ['object_class', '=', $object_class], ['object_id', '=', $object_id] ]); + $assignments = $orm->read('core\Assignment', (array) $assignments_ids, ['role']); + if($assignments > 0 && count($assignments)) { + $map_assignment_roles = []; + foreach($assignments as $aid => $assignment) { + foreach($getResultingRoles($assignment['role']) as $resulting_role) { + $map_assignment_roles[$resulting_role] = true; + } + } + if($first) { + $map_user_roles = $map_assignment_roles; + } + else { + foreach($map_user_roles as $r => $v) { + if(!isset($map_assignment_roles[$r])) { + unset($map_user_roles[$r]); + } + } + if(!count($map_user_roles)) { + break; + } + } + $first = false; + } + } + $roles = array_keys($map_user_roles); + } + + return $roles; + } + public function getGroupUsers($group_id) { $users_ids = []; if(!isset($this->usersTable[$group_id])) { @@ -76,7 +161,7 @@ public function getGroupUsers($group_id) { } /** - * Retrieve the permissions (ACL) that apply for a given user on a target entity. + * Retrieve the permissions (ACL) that apply for a given user on a target entity and/or on a set of specific objects. * This method use the AccessController cache to provide previously requested Rights. * * @param int $user_id Identifier of the user for which the permissions are requested. @@ -480,8 +565,13 @@ public function hasGroup($group, $user_id=null) { public function hasRight($user_id, $operation, $object_class='*', $objects_ids=[]) { // force cast ids to array (passing a single id is accepted) $objects_ids = (array) $objects_ids; - // permission query is for class and/or fields only (no specific objects) + // retrieve most permissive right that use has on targeted entities/objects. $user_rights = $this->getUserRights($user_id, $object_class, $objects_ids, $operation); + if(strpos($object_class, '*') === false) { + // retrieve permissions from roles, if set for given class + $user_roles = $this->getUserRoles($user_id, $object_class, $objects_ids); + $user_rights |= $this->getRightsFromRoles($user_roles, $object_class); + } // if all bits of operation are granted, then user has requested rights return (($user_rights & $operation) == $operation); } @@ -490,7 +580,7 @@ public function hasRight($user_id, $operation, $object_class='*', $objects_ids=[ * Check if current user (retrieved using Auth service) has rights to perform a given operation. * * This method is called by the Collection service, when performing CRUD. - * #todo #confirm - deprecate $object_fields (how individual can...() checks are made on fields ?) + * #todo #confirm - deprecate $object_fields (instead rely on individual can...() checks) * * @param integer $operation Identifier of the operation(s) that is/are checked (bit mask made of constants : EQ_R_CREATE, EQ_R_READ, EQ_R_DELETE, EQ_R_WRITE, EQ_R_MANAGE). * @param string $object_class Class selector indicating on which classes the check must be performed. @@ -509,49 +599,62 @@ public function isAllowed($operation, $object_class='*', $object_fields=[], $obj return $has_right; } + /** + * Retrieve granted rights based on an exhaustive list of roles. + * Rights are retrieved based on the 'rights' property ('implied_by' directive is not handled here). + */ + public function getRightsFromRoles(array $roles, string $object_class): int { + $rights = 0; + if(count($roles)) { + $def_roles = $object_class::getRoles(); + foreach($roles as $role) { + if(isset($def_roles[$role])) { + $rights |= $def_roles[$role]['rights'] ?? 0; + } + } + } + return $rights; + } + /** * Check if a given user is granted a role on a collection of objects. * - * @var integer $user_id The identifier of the user for which the test is requested. - * @var string $role The role for which assignment is being tested. + * @param integer $user_id The identifier of the user for which the test is requested. + * @param string $role The role for which assignment is being tested. * @param string $object_class Class on which the check must be performed. * @param int[] $object_ids List of objects identifiers (relating to $object_class) against which the check must be performed. */ - public function hasRole($user_id, $role, $object_class, $objects_ids=[]) { - // associative array with keys holding objects ids for which role is granted - $result = []; + public function hasRole($user_id, $role, $object_class, $objects_ids=[]): bool { + $result = true; - /** @var \equal\orm\ObjectManager */ - $orm = $this->container->get('orm'); + if(!count($object_class::getRoles())) { + $result = false; + } + else { + /** @var \equal\orm\ObjectManager */ + $orm = $this->container->get('orm'); - // build a list of all roles that cover the given role (see implied_by) - $def_roles = $object_class::getRoles(); - $map_roles = []; + $map_role_matches = []; + $map_role_matches[$role] = true; - if(isset($def_roles[$role])) { - $map_roles[$role] = true; - $desc = $def_roles[$role]; - while(isset($desc['implied_by'])) { - foreach((array) $desc['implied_by'] as $r) { - $map_roles[$r] = true; + $roles_implications = $this->getRolesImplications($object_class); + foreach((array) $roles_implications as $r => $implied_roles) { + if(in_array($role, $implied_roles)) { + $map_role_matches[$r] = true; } - $desc = $desc['implied_by']; } - } - $related_roles = array_keys($map_roles); - if(count($related_roles)) { foreach($objects_ids as $object_id) { // retrieve all assignments objects implying one or more roles of the list, given to user on given object_class, object_id - $user_roles_ids = $orm->search(Assignment::getType(), [['object_id', '=', $object_id], ['object_class', '=', $object_class], ['user_id', '=', $user_id], ['role', 'in', $related_roles]]); - if($user_roles_ids > 0 && count($user_roles_ids)) { - // map results on object_id - $result[$object_id] = true; + $assignments_ids = $orm->search(Assignment::getType(), [ ['object_id', '=', $object_id], ['object_class', '=', $object_class], ['user_id', '=', $user_id], ['role', 'in', array_keys($map_role_matches)] ]); + if($assignments_ids <= 0 || !count($assignments_ids)) { + $result = false; + break; } } } - return (count(array_keys($result)) == count($objects_ids)); + return $result; } /** @@ -616,6 +719,10 @@ public function canPerform($user_id, $action, $object_class, $object_ids) { return $result; } + public function getComplyingPolicyId() { + return $this->complying_policy_id; + } + public function isRequestCompliant($user_id, $ip_address) { // if compliance has already been evaluated to true, do not re-run the process if($this->is_request_compliant) { @@ -671,6 +778,7 @@ public function isRequestCompliant($user_id, $ip_address) { // request is compliant, stop testing other policies if($is_compliant) { $result = true; + $this->complying_policy_id = $policy['id']; break; } } diff --git a/lib/equal/data/adapt/adapters/sql/mysql/DataAdapterSqlRealMySql.class.php b/lib/equal/data/adapt/adapters/sql/mysql/DataAdapterSqlRealMySql.class.php index 51c596043..05f198e94 100644 --- a/lib/equal/data/adapt/adapters/sql/mysql/DataAdapterSqlRealMySql.class.php +++ b/lib/equal/data/adapt/adapters/sql/mysql/DataAdapterSqlRealMySql.class.php @@ -25,19 +25,19 @@ public function castInType(): string { */ public function castOutType($usage=null): string { // default values - $integer_part = 10; - $decimal_part = 2; + $precision = 10; + $scale = 2; // arg represents a numeric value (either numeric type or string) if(!is_null($usage)) { if(!($usage instanceof Usage)) { $usage = UsageFactory::create($usage); } - $decimal_part = $usage->getScale(); - $integer_part = $usage->getPrecision() + $decimal_part; + $scale = $usage->getScale(); + $precision = $usage->getPrecision() + $scale; } - return 'DECIMAL('.$integer_part.','.$decimal_part.')'; + return 'DECIMAL('.$precision.','.$scale.')'; } } diff --git a/lib/equal/db/DBManipulatorSqlSrv.class.php b/lib/equal/db/DBManipulatorSqlSrv.class.php index 251659514..84f56a629 100644 --- a/lib/equal/db/DBManipulatorSqlSrv.class.php +++ b/lib/equal/db/DBManipulatorSqlSrv.class.php @@ -55,7 +55,7 @@ public function select($db_name) { public function connect($auto_select=true) { $result = false; if(!function_exists('sqlsrv_connect')) { - throw new \Exception('Missing mandatory driver (sqlsrv).', QN_ERROR_UNKNOWN_SERVICE); + throw new \Exception('Missing mandatory driver (sqlsrv).', EQ_ERROR_UNKNOWN_SERVICE); } if($this->canConnect($this->host, $this->port)) { // prevent warnings from raising errors diff --git a/lib/equal/http/HttpHeaders.class.php b/lib/equal/http/HttpHeaders.class.php index 72270db29..2a5465b37 100644 --- a/lib/equal/http/HttpHeaders.class.php +++ b/lib/equal/http/HttpHeaders.class.php @@ -226,62 +226,57 @@ public function getLanguage() { /** - * Returns the client IP addresses. + * Returns the IP addresses of the HTTP message. + * List contains original IP (for) and, if set, a series of IP of used proxies that passed the request. * - * List is based on proxies order set in the header. - * - * - * @return array The client IP addresses + * @return array The IP addresses of the HTTP message. * * @see getIpAddress() */ private function getIpAddresses() { - $client_ips = array(); + $ip_addresses = []; if(isset($this->headers['Forwarded'])) { - preg_match_all('{(for)=("?\[?)([a-z0-9\.:_\-/]*)}', $this->headers['X-Forwarded-For'], $matches); - $client_ips = $matches[3]; + preg_match_all('/(for)=("?\[?)([a-z0-9\.:_\-\/]*)/', $this->headers['Forwarded'], $matches); + foreach($matches as $match) { + $ip_addresses[] = $match[3]; + } + preg_match_all('/(by)=("?\[?)([a-z0-9\.:_\-\/]*)/', $this->headers['Forwarded'], $matches); + foreach($matches as $match) { + $ip_addresses[] = $match[3]; + } } elseif(isset($this->headers['X-Forwarded-For'])) { - $client_ips = array_map('trim', explode(',', $this->headers['X-Forwarded-For'])); + $ip_addresses = array_map('trim', explode(',', $this->headers['X-Forwarded-For'])); } - foreach($client_ips as $key => $client_ip) { + foreach($ip_addresses as $key => $client_ip) { // remove port, if any if (preg_match('{((?:\d+\.){3}\d+)\:\d+}', $client_ip, $match)) { - $client_ips[$key] = $client_ip = $match[1]; + $ip_addresses[$key] = $match[1]; } // remove invalid addresses - if (!filter_var($client_ip, FILTER_VALIDATE_IP)) { - unset($client_ips[$key]); + if (!filter_var($ip_addresses[$key], FILTER_VALIDATE_IP)) { + unset($ip_addresses[$key]); continue; } } - // the IP chain contains only untrusted proxies and the client IP - return array_reverse($client_ips) ; + return $ip_addresses; } /** - * Returns the client IP address. - * - * This method can read the client IP address from the "X-Forwarded-For" header - * when trusted proxies were set via "setTrustedProxies()". The "X-Forwarded-For" - * header value is a comma+space separated list of IP addresses, the left-most - * being the original client, and each successive proxy that passed the request - * adding the IP address where it received the request from. + * Returns the original client IP address. * - * @return string The client IP address + * @return string The IP address of the client from which the message originates. * - * @see getIpAddresses() * @see http://en.wikipedia.org/wiki/X-Forwarded-For */ public function getIpAddress() { - $ipAddresses = $this->getIpAddresses(); - return (count($ipAddresses))?$ipAddresses[0]:''; + $ip_addresses = $this->getIpAddresses(); + return count($ip_addresses) ? $ip_addresses[0] : '127.0.0.1'; } - /** * Gets the format associated with the request. * diff --git a/lib/equal/orm/Collection.class.php b/lib/equal/orm/Collection.class.php index deb1aa412..f906b7778 100644 --- a/lib/equal/orm/Collection.class.php +++ b/lib/equal/orm/Collection.class.php @@ -1,7 +1,7 @@ - Some Rights Reserved, Cedric Francoys, 2010-2021 + This file is part of the eQual framework + Some Rights Reserved, Cedric Francoys, 2010-2024 Licensed under GNU LGPL 3 license */ namespace equal\orm; @@ -219,14 +219,14 @@ public function last($to_array=false) { /** * Provide the whole collection as a map (by default) or as an array. * - * @param boolean $to_array Flag to force conversion to an array (instead of a map). If set to true, the returned result is an of objects with keys holding indexes (and no ids). + * @param boolean $to_array Flag to force conversion to an array (instead of a map). If set to true, the returned result is a list of objects (with keys holding indexes and not ids). * @return array Returns an associative array holding objects of the collection. If $to_array is set to true, all sub-collections are recursively converted to arrays and keys are no longer mapping objects identifiers. * If the collection is empty, an empty array is returned. */ public function get($to_array=false) { $result = []; foreach($this->objects as $id => $object) { - $result[$id] = $this->_getRawObject($object, $to_array); + $result[$id] = $this->_getRawObject($object, $to_array, true); } if($to_array) { $result = array_values($result); @@ -239,9 +239,10 @@ public function get($to_array=false) { * handling sub-Collection instances either as maps or arrays. * * @param Model $object Object to be converted to an array representation. - * @param boolean $to_array Flag for requesting sub-collections as arrays (instead of a maps) + * @param boolean $to_array Flag for requesting sub-collections as lists. Default is associative array (ids mapping related objects). + * @param boolean $adapt Flag for requesting recursive adaptation for values (set to true only in call from `get()` method). */ - private function _getRawObject($object, $to_array=false) { + private function _getRawObject($object, $to_array=false, $adapt=false) { $result = []; foreach($object as $field => $value) { if($value instanceof Collection) { @@ -255,12 +256,11 @@ private function _getRawObject($object, $to_array=false) { $result[$field] = $this->_getRawObject($value, $to_array); } else { - if($to_array && $this->adapter) { - /** @var \equal\orm\Field */ + if($this->adapter && ($to_array || $adapt)) { $f = $object->getField($field); if(!$f) { // log an error and ignore adaptation - trigger_error("ORM::unexpected error when retrieving Field object for $field ({$object->getType()})", EQ_REPORT_INFO); + trigger_error("ORM::unexpected error when retrieving Field object for $field ({$object->getType()})", EQ_REPORT_WARNING); $result[$field] = $value; continue; } @@ -276,8 +276,9 @@ private function _getRawObject($object, $to_array=false) { } /** - * Provide the whole collection as an array. - */ + * Provide the whole Collection as an array. + * Objects and sub-collections will be converted to arrays as well. + */ public function toArray() { return $this->get(true); } @@ -875,30 +876,30 @@ public function update(array $values, $lang=null) { // retrieve targeted fields names $fields = array_keys($values); - // 2) check that current user has enough privilege to perform WRITE operation + // by convention, update operation sets modifier as current user + $values['modifier'] = $user_id; + // unless explicitly assigned to another value than 'draft', update operation sets state to 'instance' + if(!isset($values['state']) || $values['state'] == 'draft') { + $values['state'] = 'instance'; + } + + // 2) check that current user has enough privilege to perform the operation if(!$this->ac->isAllowed(EQ_R_WRITE, $this->class, $fields, $ids)) { throw new \Exception($user_id.';UPDATE;'.$this->class.';['.implode(',', $fields).'];['.implode(',', $ids).']', EQ_ERROR_NOT_ALLOWED); } - // if object is not yet an instance, check required fields (otherwise, we allow partial update) - $check_required = (isset($values['state']) && $values['state'] == 'draft')?true:false; - // 3) validate : check unique keys and required fields + // if object is not yet an instance, check required fields (otherwise, partial update is allowed) + $check_required = (isset($values['state']) && $values['state'] == 'draft') ? true : false; $this->validate($values, $ids, true, $check_required); - // 4) update objects - // by convention, update operation sets modifier as current user - $values['modifier'] = $user_id; - // unless explicitly assigned (to another value than 'draft'), update operation always sets state to 'instance' - if(!isset($values['state']) || $values['state'] == 'draft') { - $values['state'] = 'instance'; - } - - $canupdate = $this->call('canupdate', $values); + // check if fields (other than special columns) can be updated + $canupdate = $this->call('canupdate', array_diff_key($values, Model::getSpecialColumns())); if(!empty($canupdate)) { throw new \Exception(serialize($canupdate), QN_ERROR_NOT_ALLOWED); } + // 4) update objects $res = $this->orm->update($this->class, $ids, $values, ($lang)?$lang:$this->lang); if($res <= 0) { trigger_error("ORM::unexpected error when updating {$this->class} objects:".$this->orm->getLastError(), EQ_REPORT_INFO); @@ -1007,4 +1008,117 @@ public function do($action) { return $this; } + /** + * Following methods are defined here to allow equal\orm\Model children classes having arbitrary parameters. + * These methods are defined to prevent PHP strict errors & aot warnings, and are called through the + * Collection::call() method which, in turn, invokes the class the Collection relates to. + * If the method is not defined in the child class, a new Collection is instantiated and the call is applied to it (hence their definition here). + */ + + /** + * Check wether an object can be read by current user. + * This method can be overridden to define a custom set of tests (based on roles and/or policies). + * + * Accepts variable list of arguments, based on their names (@see \equal\orm\Model class for list of available). + * @return array Returns an associative array mapping ids and fields with their error messages. An empty array means that object has been successfully processed and can be read. + */ + public static function canread(...$params) { + return []; + } + + /** + * Check wether an object can be created. + * These tests come in addition to the unique constraints return by method `getUnique()`. + * This method can be overridden to define a custom set of tests. + * + * Accepts variable list of arguments, based on their names (@see \equal\orm\Model class for list of available). + * @return array Returns an associative array mapping fields with their error messages. An empty array means that object has been successfully processed and can be created. + */ + public static function cancreate(...$params) { + return []; + } + + /** + * Check wether an object can be updated. + * These tests come in addition to the unique constraints return by method `getUnique()`. + * This method can be overridden to define a custom set of tests. + * + * Accepts variable list of arguments, based on their names (@see \equal\orm\Model class for list of available). + * @return array Returns an associative array mapping fields with their error messages. An empty array means that object has been successfully processed and can be updated. + */ + public static function canupdate(...$params) { + return []; + } + + /** + * Check wether an object can be cloned. + * These tests come in addition to the unique constraints return by method `getUnique()`. + * This method can be overridden to define a custom set of tests. + * + * Accepts variable list of arguments, based on their names (@see \equal\orm\Model class for list of available). + * @return array Returns an associative array mapping ids with their error messages. An empty array means that object has been successfully processed and can be updated. + */ + public static function canclone(...$params) { + return []; + } + + /** + * Check wether an object can be deleted. + * This method can be overridden to define a custom set of tests. + * + * Accepts variable list of arguments, based on their names (@see \equal\orm\Model class for list of available). + * @return array Returns an associative array mapping ids with their error messages. An empty array means that object has been successfully processed and can be deleted. + */ + public static function candelete(...$params) { + return []; + } + + /** + * Hook invoked after object creation for performing object-specific additional operations. + * + * Accepts variable list of arguments, based on their names (@see \equal\orm\Model class for list of available). + * @return void + */ + public static function oncreate(...$params) { + } + + /** + * Hook invoked before object update for performing object-specific additional operations. + * Current values of the object can still be read for comparing with new values. + * + * Accepts variable list of arguments, based on their names (@see \equal\orm\Model class for list of available). + * @return void + */ + public static function onupdate(...$params) { + } + + /** + * Hook invoked after object cloning for performing object-specific additional operations. + * + * Accepts variable list of arguments, based on their names (@see \equal\orm\Model class for list of available). + * @return void + */ + public static function onclone(...$params) { + } + + /** + * Hook invoked before object deletion for performing object-specific additional operations. + * + * Accepts variable list of arguments, based on their names (@see \equal\orm\Model class for list of available). + * @return void + */ + public static function ondelete(...$params) { + } + + /** + * Hook invoked by UI for single object values change. + * This method does not imply an actual update of the model, but a potential one (not made yet) and is intended for front-end only. + * + * Accepts variable list of arguments, based on their names (@see \equal\orm\Model class for list of available). + * @return void + */ + public static function onchange(...$params) { + return []; + } + } diff --git a/lib/equal/orm/Model.class.php b/lib/equal/orm/Model.class.php index 1c947ea60..34137ac7f 100644 --- a/lib/equal/orm/Model.class.php +++ b/lib/equal/orm/Model.class.php @@ -1,7 +1,7 @@ - Some Rights Reserved, Cedric Francoys, 2010-2021 + This file is part of the eQual framework + Some Rights Reserved, Cedric Francoys, 2010-2024 Licensed under GNU LGPL 3 license */ namespace equal\orm; @@ -10,7 +10,7 @@ /** * Root Model for all Object definitions. - * This class holds the description of an object along with the values of the currently assigned fields. + * This class holds the description of an object along with the values of the currently loaded/assigned fields. * * List of static methods for building new Collection objects (accessed through magic methods): * @method \equal\orm\Collection id($id) @@ -18,17 +18,36 @@ * @method \equal\orm\Collection search(array $domain=[], array $params=[], $lang=null) * @method \equal\orm\Collection create(array $values=null, $lang=null) * - * List of static method with variable parameters: - * @method array canread($orm, $ids=[], $fields=[], $lang='en') - * @method array cancreate($orm, $values=[], $lang='en') - * @method array canupdate($orm, $ids=[], $values=[], $lang='en') - * @method array canclone($orm, $ids=[]) - * @method array candelete($orm, $ids=[]) - * @method array onchange($orm, $event=[], $values=[], $lang='en') - * @method void oncreate($orm, $ids=[], $values=[], $lang='en') - * @method void onupdate($orm, $ids=[], $values=[], $lang='en') - * @method void onclone($orm, $ids=[]) - * @method void ondelete($orm, $ids=[]) + * List of static methods with variable parameters: + * (These methods are handled through __callStatic method to prevent PHP strict errors & aot warnings.) + * + * 1) `can...()` methods - consistency checkers: + * These methods return an associative array mapping fields with their error messages. An empty array means that use can perform the action. + * @method array canread(mixed ...$params) Check wether an object can be read by current user. + * @method array cancreate(mixed ...$params) Check wether an object can be created. + * @method array canupdate(mixed ...$params) Check wether an object can be updated. + * @method array candelete(mixed ...$params) Check wether an object can be deleted. + * @method array canclone(mixed ...$params) Check wether an object can be cloned. + * + * 2) `on...()` methods - event handlers: + * @method array onchange(mixed ...$params) Hook invoked by UI for single object values change. Returns an associative array mapping fields with new (virtual) values to be set in UI (not saved yet). + * @method void oncreate(mixed ...$params) Hook invoked AFTER object creation for performing object-specific additional operations. + * @method void onupdate(mixed ...$params) Hook invoked BEFORE object update for performing object-specific additional operations. + * @method void ondelete(mixed ...$params) Hook invoked BEFORE object deletion for performing object-specific additional operations. + * @method void onclone(mixed ...$params) Hook invoked AFTER object cloning for performing object-specific additional operations. + * + * Possible params: + * - Collection $self Collection holding a series of objects of current class. + * - array $ids List of objects identifiers in current collection. + * - array $event Associative array holding changed fields as keys, and their related new values. + * - array $values Copy of the current (partial) state of the object. + * - string $lang Lang ISO code, falls back to DEFAULT_LANG. + * - equal\orm\ObjectManager $orm Instance of ObjectManager service. + * - equal\access\AccessController $access Instance of AccessController service. + * - equal\auth\AuthenticationManager $auth Instance of AuthenticationManager service. + * - equal\php\Context $context Instance of Context service. + * - equal\error\Reporter $reporter Instance of error Reporter service. + * - equal\data\DataAdapter $adapt Instance of DataAdapter service. */ class Model implements \ArrayAccess, \Iterator { @@ -520,50 +539,11 @@ public function getTable() { return strtolower(str_replace('\\', '_', $entity)); } - public static function id($id) { - return self::ids((array) $id); - } - - public static function ids($ids) { - if(is_callable('equal\orm\Collections::getInstance')) { - /** @var Collections */ - $factory = Collections::getInstance(); - /** @var Collection */ - $collection = $factory->create(get_called_class()); - return $collection->ids($ids); - } - return null; - } - - public static function search(array $domain=[], array $params=[], $lang=null) { - if(is_callable('equal\orm\Collections::getInstance')) { - /** @var Collections */ - $factory = Collections::getInstance(); - /** @var Collection */ - $collection = $factory->create(get_called_class()); - return $collection->search($domain, $params, $lang); - } - return null; - } - - public static function create(array $values=null, $lang=null) { - if(is_callable('equal\orm\Collections::getInstance')) { - /** @var Collections */ - $factory = Collections::getInstance(); - /** @var Collection */ - $collection = $factory->create(get_called_class()); - return $collection->create($values, $lang); - } - return null; - } - /** * Handler for virtual static methods: use classname to invoke a Collection method, if available. - * #todo - deprecate : since we cover all entry points with static methods, magic methods should no longer be involved. * * @param string $name Name of the called method. * @param array $arguments Array holding a list of arguments to relay to the invoked method. - * @deprecated */ public static function __callStatic($name, $arguments) { if(is_callable('equal\orm\Collections::getInstance')) { diff --git a/lib/equal/orm/ObjectManager.class.php b/lib/equal/orm/ObjectManager.class.php index dd3b7ddb5..a39842872 100644 --- a/lib/equal/orm/ObjectManager.class.php +++ b/lib/equal/orm/ObjectManager.class.php @@ -174,7 +174,7 @@ public function __destruct() { } public static function constants() { - return ['DEFAULT_LANG', 'UPLOAD_MAX_FILE_SIZE', 'FILE_STORAGE_MODE', 'DRAFT_VALIDITY']; + return ['DEFAULT_LANG', 'UPLOAD_MAX_FILE_SIZE', 'FILE_STORAGE_MODE', 'DRAFT_VALIDITY', 'ORM_EVENTS_FORCE_ONUPDATE_AT_CREATION']; } /** @@ -191,7 +191,7 @@ public function getDB() { * * @return DBConnector */ - private function getDBHandler() { + private function getDbHandler() { // open DB connection, if not connected yet if(!$this->db->connected()) { if($this->db->connect() === false) { @@ -472,10 +472,13 @@ private function sanitizeIdentifiers($ids) { public function filterExistingIdentifiers($class, $ids) { $ids = $this->sanitizeIdentifiers($ids); - if(!empty($ids)) { - $map_valid_ids = []; + if(empty($ids)) { + return []; + } + try { // get DB handler (init DB connection if necessary) - $db = $this->getDBHandler(); + $db = $this->getDbHandler(); + $map_valid_ids = []; $table_name = $this->getObjectTableName($class); // get all records at once $result = $db->getRecords($table_name, 'id', $ids); @@ -490,6 +493,9 @@ public function filterExistingIdentifiers($class, $ids) { } } } + catch(\Exception $e) { + // unexpected error (DB connection) + } return $ids; } @@ -504,12 +510,13 @@ private function load($class, $ids, $fields, $lang) { $object = $this->getStaticInstance($class); // get the complete schema of the object (including special fields) $schema = $object->getSchema(); - // get DB handler (init DB connection if necessary) - $db = $this->getDBHandler(); // retrieve the name of the DB table associated to the class $table_name = $this->getObjectTableName($class); try { + // get DB handler (init DB connection if necessary) + $db = $this->getDbHandler(); + // array holding functions to load each type of fields $load_fields = [ // 'alias' @@ -771,12 +778,12 @@ private function store($class, $ids, $fields, $lang) { $object = $this->getStaticInstance($class); // get the complete schema of the object (including special fields) $schema = $object->getSchema(); - // get DB handler (init DB connection if necessary) - $db = $this->getDBHandler(); // retrieve the name of the DB table associated to the class $table_name = $this->getObjectTableName($class); try { + // get DB handler (init DB connection if necessary) + $db = $this->getDbHandler(); // array holding functions to store each type of fields $store_fields = array( // 'multilang' is a particular case of simple field) @@ -1435,11 +1442,12 @@ public function validate($class, $ids, $values, $check_unique=false, $check_requ */ public function create($class, $fields=null, $lang=null, $use_draft=true) { $res = 0; - // get DB handler (init DB connection if necessary) - $db = $this->getDBHandler(); $lang = ($lang)?$lang:constant('DEFAULT_LANG'); try { + // get DB handler (init DB connection if necessary) + $db = $this->getDbHandler(); + // get static instance (checks that given class exists) $object = $this->getStaticInstance($class, $fields); // retrieve schema $schema = $object->getSchema(); @@ -1466,17 +1474,7 @@ public function create($class, $fields=null, $lang=null, $use_draft=true) { } } - // 2) make sure objects in the collection can be updated - - /* - // #moved to Collection - $cancreate = $this->call($class, 'cancreate', [], array_diff_key($fields, $special_fields), $lang, ['values', 'lang']); - if(!empty($cancreate)) { - throw new \Exception(serialize($cancreate), QN_ERROR_NOT_ALLOWED); - } - */ - - // 3) garbage collect: check for expired draft object + // 2) garbage collect: check for expired draft object // by default, request a new object ID $oid = 0; @@ -1514,7 +1512,7 @@ public function create($class, $fields=null, $lang=null, $use_draft=true) { $oid = (int) $creation_array['id']; } - // 4) create a new record with the found value, (if no id is given, the autoincrement will assign a value) + // 3) create a new record with the found value, (if no id is given, the autoincrement will assign a value) $sql_values = []; /** @var \equal\data\adapt\DataAdapterProvider */ $dap = $this->container->get('adapt'); @@ -1537,7 +1535,7 @@ public function create($class, $fields=null, $lang=null, $use_draft=true) { // in any case, we return the object id $res = $oid; - // 5) update new object with given fields values, if any + // 4) update new object with given fields values, if any // build creation array with actual object values (#memo - fields are mapped with PHP values, not SQL) $creation_array = array_merge( $creation_array, $object->getValues(), $fields ); @@ -1582,11 +1580,11 @@ public function write($class, $ids=null, $fields=null, $lang=null, $create=false public function update($class, $ids=null, $fields=null, $lang=null, $create=false) { // init result $res = []; - // get DB handler (init DB connection if necessary) - $this->getDBHandler(); $lang = ($lang)?$lang:constant('DEFAULT_LANG'); try { + // get DB handler (init DB connection if necessary) + $db = $this->getDbHandler(); // 1) pre-processing - $ids sanitization @@ -1708,9 +1706,9 @@ public function update($class, $ids=null, $fields=null, $lang=null, $create=fals $this->store($class, $ids, array_keys($fields), $lang); - // 7) second pass : handle onupdate events, if any + // 7) second pass : handle fields onupdate events, if any - if(!$create) { + if(!$create || constant('ORM_EVENTS_FORCE_ONUPDATE_AT_CREATION')) { // #memo - this must be done after modifications otherwise object values might be outdated if(count($onupdate_fields)) { // #memo - several onupdate callbacks can, in turn, trigger a same other callback, which must then be called as many times as necessary @@ -1850,11 +1848,11 @@ public function update($class, $ids=null, $fields=null, $lang=null, $create=fals public function read($class, $ids=null, $fields=null, $lang=null) { // init result $res = []; - // get DB handler (init DB connection if necessary) - $db = $this->getDBHandler(); $lang = ($lang)?$lang:constant('DEFAULT_LANG'); try { + // get DB handler (init DB connection if necessary) + $db = $this->getDbHandler(); // 1) pre-processing: params sanitization @@ -2010,9 +2008,9 @@ public function read($class, $ids=null, $fields=null, $lang=null) { foreach($ids as $oid) { // #memo - for unreachable values, m2o are always set to null, and m2m or o2m to an empty array $sub_ids = $this->cache[$table_name][$oid][$lang][$field]; - if(isset($sub_ids) || count($sub_ids)) { + if(isset($sub_ids)) { $sub_values = $this->read($descriptor['foreign_object'], (array) $sub_ids, (array) $sub_path, $lang); - if($sub_values <= 0) { + if($sub_values <= 0 || !count($sub_values)) { continue; } if($field_type == 'many2one') { @@ -2056,11 +2054,11 @@ public function remove($class, $ids, $permanent=false) { * @return integer|array Returns a list of ids of deleted objects, or an error identifier in case an error occurred. */ public function delete($class, $ids, $permanent=false) { - // get DB handler (init DB connection if necessary) - $db = $this->getDBHandler(); $res = []; try { + // get DB handler (init DB connection if necessary) + $db = $this->getDbHandler(); // 1) pre-processing @@ -2086,8 +2084,13 @@ public function delete($class, $ids, $permanent=false) { */ // 3) call 'ondelete' hook : notify objects that they're about to be deleted - // #todo allow explicit notation 'onbeforedelete' - $this->callonce($class, 'ondelete', $ids, [], null, ['ids']); + + if(method_exists($class, 'onbeforedelete')) { + $this->callonce($class, 'onbeforedelete', $ids, [], null, ['ids']); + } + else { + $this->callonce($class, 'ondelete', $ids, [], null, ['ids']); + } // 4) cascade deletions / relations updates @@ -2207,14 +2210,6 @@ public function clone($class, $id, $values=[], $lang=null, $parent_field='') { $object = $this->getStaticInstance($class); $schema = $object->getSchema(); - /* - // #moved to Collection - $canclone = $this->call($class, 'canclone', (array) $id, [], $lang, ['ids']); - if(!empty($canclone)) { - throw new \Exception(serialize($canclone), QN_ERROR_NOT_ALLOWED); - } - */ - // read full object $res_r = $this->read($class, $id, array_keys($schema), $lang); @@ -2299,8 +2294,10 @@ public function clone($class, $id, $values=[], $lang=null, $parent_field='') { */ public function fetchAndAdd($class, $ids, $field, $increment) { $result = []; - $db = $this->getDBHandler(); + try { + // get DB handler (init DB connection if necessary) + $db = $this->getDbHandler(); // get static instance (checks that given class exists) $object = $this->getStaticInstance($class); // retrieve schema @@ -2479,12 +2476,12 @@ public function transition($class, $ids, $transition) { */ public function search($class, $domain=null, $sort=['id' => 'asc'], $start='0', $limit='0', $lang=null) { $result = []; - - // get DB handler (init DB connection if necessary) - $db = $this->getDBHandler(); $lang = ($lang)?$lang:constant('DEFAULT_LANG'); try { + // get DB handler (init DB connection if necessary) + $db = $this->getDbHandler(); + if(empty($sort)) { throw new Exception("sorting order field cannot be empty", QN_ERROR_MISSING_PARAM); } diff --git a/lib/equal/php/Context.class.php b/lib/equal/php/Context.class.php index 327964fab..c085e2379 100644 --- a/lib/equal/php/Context.class.php +++ b/lib/equal/php/Context.class.php @@ -196,14 +196,14 @@ private function getHttpRequestHeaders() { // 1) retrieve headers $headers = []; - if (function_exists('getallheaders')) { + if(function_exists('getallheaders')) { $all_headers = (array) getallheaders(); foreach($all_headers as $header => $value) { $headers[HttpHeaders::normalizeName($header)] = $value; } } else { - foreach ($_SERVER as $header => $value) { + foreach($_SERVER as $header => $value) { // convert back headers with `HTTP_` prefix if(substr($header, 0, 5) === 'HTTP_' || in_array($header, ['CONTENT_TYPE', 'CONTENT_LENGTH', 'CONTENT_MD5'])) { $header = str_replace(['HTTP_', '_'], '', $header); @@ -240,34 +240,24 @@ private function getHttpRequestHeaders() { if(!isset($headers['ETag'])) { $headers['ETag'] = $headers['If-None-Match'] ?? ''; } - // handle client IP address: make sure that 'X-Forwarded-For' is always set with the most probable client IP - // fallback to localhost/127.0.0.1 (using CLI, REMOTE_ADDR is not set) - $client_ip = '127.0.0.1'; - if(isset($_SERVER['HTTP_X_FORWARDED_FOR'])) { - $client_ip = $_SERVER['HTTP_X_FORWARDED_FOR']; + // handle client IP address + // use only REMOTE_ADDR, if present (to prevent spoofing) - fallback to localhost + $headers['X-Forwarded-For'] = '127.0.0.1'; + // #memo - using CLI, REMOTE_ADDR is not set + if(isset($_SERVER['REMOTE_ADDR'])) { + $headers['X-Forwarded-For'] = $_SERVER['REMOTE_ADDR']; } - elseif(isset($_SERVER['REMOTE_ADDR'])) { - $client_ip = $_SERVER['REMOTE_ADDR']; - } - if(!isset($headers['X-Forwarded-For'])) { - $headers['X-Forwarded-For'] = $client_ip; - } - // assign X-Forwarded-For with a single IP (first in list) - $headers['X-Forwarded-For'] = explode(',', $headers['X-Forwarded-For'])[0]; } - if(isset($headers['content-type'])) { - $headers['Content-Type'] = $headers['content-type']; + + // set default content type to 'application/x-www-form-urlencoded' + if(!isset($headers['Content-Type'])) { + $headers['Content-Type'] = 'application/x-www-form-urlencoded'; } // adapt Content-Type for multipart/form-data (already parsed by PHP) - if(isset($headers['Content-Type'])) { - if($this->getHttpMethod() == 'POST' && strpos($headers['Content-Type'], 'multipart/form-data') === 0) { - $headers['Content-Type'] = 'application/x-www-form-urlencoded'; - } - } - else { - // will be parsed using parse_str + elseif($this->getHttpMethod() == 'POST' && strpos($headers['Content-Type'], 'multipart/form-data') === 0) { $headers['Content-Type'] = 'application/x-www-form-urlencoded'; } + return $headers; } diff --git a/lib/equal/services/Container.class.php b/lib/equal/services/Container.class.php index dbae7be12..0ec2cd416 100644 --- a/lib/equal/services/Container.class.php +++ b/lib/equal/services/Container.class.php @@ -19,11 +19,11 @@ class Container extends Service { public function __construct() { $this->instances = []; - $this->register = &$GLOBALS['QN_SERVICES_POOL']; + $this->register = &$GLOBALS['EQ_SERVICES_POOL']; } public static function constants() { - return ['QN_ERROR_UNKNOWN_SERVICE']; + return ['EQ_ERROR_UNKNOWN_SERVICE']; } public function register($name='', $class=null) { @@ -155,7 +155,7 @@ public function get($name) { } // some dependencies are missing else { - throw new \Exception($name, QN_ERROR_UNKNOWN_SERVICE); + throw new \Exception($name, EQ_ERROR_UNKNOWN_SERVICE); } } // push instance into result array (instance might be null) @@ -165,7 +165,7 @@ public function get($name) { if(count($names) == 1) { $instances = (count($instances))?$instances[0]:false; } - else if(count($names) != count($instances)) { + elseif(count($names) != count($instances)) { $instances = false; } return $instances; diff --git a/lib/equal/test/Tester.class.php b/lib/equal/test/Tester.class.php index 4f5719354..3ee188dc0 100644 --- a/lib/equal/test/Tester.class.php +++ b/lib/equal/test/Tester.class.php @@ -6,6 +6,8 @@ */ namespace equal\test; +use equal\orm\Model; + class Tester { // array of failing tests @@ -157,7 +159,15 @@ public function test($test_id=0) { } $this->results[$id]['status'] = $success?'ok':'ko'; - $this->results[$id]['result'] = (gettype($result) == 'object')?'('.get_class($result).' object)':$result; + + $result_txt = $result; + if(gettype($result) == 'object') { + $result_txt = get_class($result).' object'; + if(is_a($result, Model::getType())) { + $result_txt .= ':'.json_encode($result->toArray()); + } + } + $this->results[$id]['result'] = $result_txt; if(isset($test['expected'])) { $this->results[$id]['expected'] = $test['expected']; diff --git a/packages/core/actions/init/composer.php b/packages/core/actions/init/composer.php index ba48d7890..9e0ad0e1e 100644 --- a/packages/core/actions/init/composer.php +++ b/packages/core/actions/init/composer.php @@ -61,8 +61,8 @@ unlink(EQ_BASEDIR.'/composer.lock'); } -// run composer to install dependencies (quiet mode, no interactions) -if(exec('php composer.phar install -q -n') === false) { +// run composer to install dependencies (quiet mode, no interactions, ignore PHP version) +if(exec('php composer.phar install --ignore-platform-reqs -q -n') === false) { throw new Exception('composer_failed', EQ_ERROR_UNKNOWN); } diff --git a/packages/core/actions/init/fs.php b/packages/core/actions/init/fs.php index 9999b873b..c61ddffb1 100644 --- a/packages/core/actions/init/fs.php +++ b/packages/core/actions/init/fs.php @@ -59,7 +59,7 @@ exec("id -u \"$username\" 2>&1", $output, $result_code); if($result_code !== 0) { - throw new Exception('uid_unavailable : '.implode("\n", $output), EQ_ERROR_UNKNOWN); + throw new Exception(serialize(['uid_unavailable' => implode("\n", $output)]), EQ_ERROR_UNKNOWN); } $output = (array) $output; diff --git a/packages/core/actions/init/package.php b/packages/core/actions/init/package.php index 205a19053..947586a95 100644 --- a/packages/core/actions/init/package.php +++ b/packages/core/actions/init/package.php @@ -61,7 +61,7 @@ 'default' => true ] ], - 'constants' => ['DB_HOST', 'DB_PORT', 'DB_NAME', 'DB_USER', 'DB_PASSWORD', 'DB_DBMS'], + 'constants' => ['DEFAULT_LANG', 'DB_HOST', 'DB_PORT', 'DB_NAME', 'DB_USER', 'DB_PASSWORD', 'DB_DBMS'], 'providers' => ['context', 'orm', 'adapt', 'report'], ]); @@ -83,7 +83,13 @@ } } -if(!$skip_package) { +if($skip_package) { + if($params['root']) { + throw new Exception('package_already_initialized', EQ_ERROR_CONFLICT_OBJECT); + } + // silently ignore package when dependency of another +} +else { // retrieve adapter for converting data from JSON files $adapter = $dap->get('json'); @@ -167,12 +173,18 @@ $data_folder = "packages/{$params['package']}/init/data"; if($params['import'] && file_exists($data_folder) && is_dir($data_folder)) { // handle JSON files - foreach (glob($data_folder."/*.json") as $json_file) { + foreach(glob($data_folder."/*.json") as $json_file) { $data = file_get_contents($json_file); $classes = json_decode($data, true); + if(!$classes) { + continue; + } foreach($classes as $class) { - $entity = $class['name']; - $lang = $class['lang']; + $entity = $class['name'] ?? null; + if(!$entity) { + continue; + } + $lang = $class['lang'] ?? constant('DEFAULT_LANG'); $model = $orm->getModel($entity); $schema = $model->getSchema(); @@ -222,12 +234,18 @@ $demo_folder = "packages/{$params['package']}/init/demo"; if($params['demo'] && file_exists($demo_folder) && is_dir($demo_folder)) { // handle JSON files - foreach (glob($demo_folder."/*.json") as $json_file) { + foreach(glob($demo_folder."/*.json") as $json_file) { $data = file_get_contents($json_file); $classes = json_decode($data, true); + if(!$classes) { + continue; + } foreach($classes as $class) { - $entity = $class['name']; - $lang = $class['lang']; + $entity = $class['name'] ?? null; + if(!$entity) { + continue; + } + $lang = $class['lang'] ?? constant('DEFAULT_LANG'); $model = $orm->getModel($entity); $schema = $model->getSchema(); @@ -273,12 +291,18 @@ $test_folder = "packages/{$params['package']}/init/test"; if($params['test'] && file_exists($test_folder) && is_dir($test_folder)) { // handle JSON files - foreach (glob($test_folder."/*.json") as $json_file) { + foreach(glob($test_folder."/*.json") as $json_file) { $data = file_get_contents($json_file); $classes = json_decode($data, true); + if(!$classes) { + continue; + } foreach($classes as $class) { - $entity = $class['name']; - $lang = $class['lang']; + $entity = $class['name'] ?? null; + if(!$entity) { + continue; + } + $lang = $class['lang'] ?? constant('DEFAULT_LANG'); $model = $orm->getModel($entity); $schema = $model->getSchema(); @@ -424,6 +448,15 @@ $map_composer['require'][$dependency] = $version; } + // remove unused/empty + if(empty($map_composer['require'])) { + unset($map_composer['require']); + } + + if(empty($map_composer['require-dev'])) { + unset($map_composer['require-dev']); + } + file_put_contents(EQ_BASEDIR.'/composer.json', json_encode($map_composer, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); } diff --git a/packages/core/actions/test/email.php b/packages/core/actions/test/email.php new file mode 100644 index 000000000..38b2ce89b --- /dev/null +++ b/packages/core/actions/test/email.php @@ -0,0 +1,56 @@ + + Some Rights Reserved, Cedric Francoys, 2010-2024 + Licensed under GNU LGPL 3 license +*/ +use equal\email\Email; +use core\Mail; + +// announce script and fetch parameters values +list($params, $providers) = eQual::announce([ + 'description' => "Send a test email.", + 'constants' => ['BACKEND_URL', 'EMAIL_SMTP_ACCOUNT_EMAIL'], + 'response' => [ + 'content-type' => 'application/json', + 'charset' => 'utf-8', + 'accept-origin' => '*' + ], + 'providers' => ['context'] +]); + +/** + * @var \equal\php\Context $context + */ +['context' => $context] = $providers; + +// fetch content of test message +if(!($html = @file_get_contents(EQ_BASEDIR."/packages/core/i18n/en/mail_test.html"))) { + throw new Exception("missing_template", EQ_ERROR_INVALID_CONFIG); +} + +// parse template +$body = (function ($template, $map_values) { + foreach ($map_values as $key => $value) { + $pattern = '/{{\s*' . preg_quote($key) . '\s*}}/'; + $template = preg_replace($pattern, $value, $template); + } + return $template; + })($html, [ + 'url' => constant('BACKEND_URL') + ]); + +// create message +$message = new Email(); + +$message->setTo(constant('EMAIL_SMTP_ACCOUNT_EMAIL')) + ->setSubject('Test Email from eQual') + ->setContentType("text/html") + ->setBody($body); + +// send instant message +Mail::send($message); + +$context->httpResponse() + ->status(204) + ->send(); diff --git a/packages/core/actions/test/fs-consistency.php b/packages/core/actions/test/fs-consistency.php index 44e1ef43d..88b5da680 100644 --- a/packages/core/actions/test/fs-consistency.php +++ b/packages/core/actions/test/fs-consistency.php @@ -82,7 +82,7 @@ function check_permissions($path, $mask, $uid=0) { } } // check group permissions - else if($fgid == $uid) { + elseif($fgid == $uid) { if( ($mask & EQ_R_READ) && !($perms & 0x0020)) { return -EQ_R_READ; } @@ -135,7 +135,7 @@ function check_permissions($path, $mask, $uid=0) { default: $missing = ''; } - throw new Exception("PHP or HTTP process has no {$missing} access on {$item['path']}", EQ_ERROR_INVALID_CONFIG); + throw new Exception("PHP or HTTP process ($username) has no {$missing} access on {$item['path']}", EQ_ERROR_INVALID_CONFIG); } } diff --git a/packages/core/actions/user/create.php b/packages/core/actions/user/create.php index 013d85d22..f78730c86 100644 --- a/packages/core/actions/user/create.php +++ b/packages/core/actions/user/create.php @@ -1,7 +1,7 @@ - Some Rights Reserved, Cedric Francoys, 2010-2021 + This file is part of the eQual framework + Some Rights Reserved, Cedric Francoys, 2010-2024 Licensed under GNU LGPL 3 license */ @@ -44,10 +44,10 @@ 'default' => constant('DEFAULT_LANG') ] ], - 'constants' => ['DEFAULT_LANG'], 'access' => [ 'visibility' => 'private' ], + 'constants' => ['DEFAULT_LANG'], 'providers' => ['context', 'orm'] ]); diff --git a/packages/core/actions/user/pass-recover.php b/packages/core/actions/user/pass-recover.php index bc96e61bb..74890fb74 100644 --- a/packages/core/actions/user/pass-recover.php +++ b/packages/core/actions/user/pass-recover.php @@ -1,7 +1,7 @@ - Some Rights Reserved, Cedric Francoys, 2010-2021 + This file is part of the eQual framework + Some Rights Reserved, Cedric Francoys, 2010-2024 Licensed under GNU LGPL 3 license */ use equal\html\HtmlTemplate; @@ -20,12 +20,15 @@ 'required' => true ] ], - 'constants' => ['ROOT_APP_URL', 'EMAIL_SMTP_ABUSE_EMAIL', 'EMAIL_SMTP_ACCOUNT_DISPLAYNAME'], + 'access' => [ + 'visibility' => 'public' + ], 'response' => [ 'content-type' => 'application/json', 'charset' => 'utf-8', 'accept-origin' => '*' ], + 'constants' => ['ROOT_APP_URL', 'EMAIL_SMTP_ABUSE_EMAIL', 'EMAIL_SMTP_ACCOUNT_DISPLAYNAME'], 'providers' => ['context', 'orm', 'auth'] ]); diff --git a/packages/core/actions/user/signin.php b/packages/core/actions/user/signin.php index 761157297..988180cc2 100644 --- a/packages/core/actions/user/signin.php +++ b/packages/core/actions/user/signin.php @@ -1,7 +1,7 @@ - Some Rights Reserved, Cedric Francoys, 2010-2021 + This file is part of the eQual framework + Some Rights Reserved, Cedric Francoys, 2010-2024 Licensed under GNU LGPL 3 license */ use core\User; @@ -21,6 +21,9 @@ 'required' => true ] ], + 'access' => [ + 'visibility' => 'public' + ], 'response' => [ 'content-type' => 'application/json', 'charset' => 'utf-8', diff --git a/packages/core/actions/user/signup.php b/packages/core/actions/user/signup.php index 7430a3715..ff7776e60 100644 --- a/packages/core/actions/user/signup.php +++ b/packages/core/actions/user/signup.php @@ -51,7 +51,6 @@ 'default' => 0 ] ], - 'constants' => ['USER_ACCOUNT_REGISTRATION', 'DEFAULT_LANG', 'EMAIL_SMTP_HOST', 'EMAIL_SMTP_ACCOUNT_DISPLAYNAME'], 'access' => [ 'visibility' => 'public' ], @@ -60,6 +59,7 @@ 'charset' => 'utf-8', 'accept-origin' => '*' ], + 'constants' => ['USER_ACCOUNT_REGISTRATION', 'DEFAULT_LANG', 'EMAIL_SMTP_HOST', 'EMAIL_SMTP_ACCOUNT_DISPLAYNAME'], 'providers' => ['context', 'orm', 'auth'] ]); diff --git a/packages/core/classes/Mail.class.php b/packages/core/classes/Mail.class.php index 17ffa437e..d217856ae 100644 --- a/packages/core/classes/Mail.class.php +++ b/packages/core/classes/Mail.class.php @@ -13,7 +13,7 @@ class Mail extends Model { - const MESSAGE_FOLDER = QN_BASEDIR.'/spool'; + const MESSAGE_FOLDER = EQ_BASEDIR.'/spool'; public static function getColumns() { return [ @@ -35,7 +35,8 @@ public static function getColumns() { 'to' => [ 'type' => 'string', - 'usage' => 'email' + 'usage' => 'email', + 'required' => true ], 'reply_to' => [ @@ -54,12 +55,14 @@ public static function getColumns() { ], 'subject' => [ - 'type' => 'string' + 'type' => 'string', + 'required' => true ], 'body' => [ 'type' => 'string', - 'usage' => 'text/html' + 'usage' => 'text/html', + 'required' => true ], 'attachments' => [ @@ -95,58 +98,74 @@ public static function getColumns() { } /** - * Add a message to the email outbox. - * This method is a substitute for the create() method. + * Queue a message in the email outbox (/spool). * * @param Email $email Email message to be sent. * @param string $object_class Class of the object associated with the sending (optional). * @param string $object_id Identifier of the object associated with the sending (optional). - * @return int Upon success, this method returns the id of the `core\Mail` object created for the sending. + * + * @return int Upon success, this method returns the id of the queued `core\Mail` object. + * * @throws \Exception This method raises an Exception in case of error. */ - public static function queue(Email $email, string $object_class='', int $object_id=0): int { - // create an Object - $values = [ - 'to' => $email->to, - 'cc' => implode(',', (array) $email->cc), - 'bcc' => implode(',', (array) $email->bcc), - 'subject' => $email->subject, - // remove utf8mb4 chars (emojis) - // 'body' => preg_replace('/(?:\xF0[\x90-\xBF][\x80-\xBF]{2} | [\xF1-\xF3][\x80-\xBF]{3} | \xF4[\x80-\x8F][\x80-\xBF]{2})/xs', '', $email->body), - // #memo - DB is set to UTF8mb4 by default - 'body' => $email->body, - 'attachments' => '', - 'object_class' => $object_class, - 'object_id' => $object_id - ]; + public static function queue(Email $email, string $object_class = '', int $object_id = 0): int { + $mail = self::createMail($email, $object_class, $object_id); - if(isset($email->reply_to) && !empty($email->reply_to)) { - $values['reply_to'] = $email->reply_to; - } - // extract attachment names, if any - if(count($email->attachments)) { - $attachments = array_map(function ($a) {return $a->name;}, $email->attachments); - $values['attachments'] = implode("\n", $attachments); - } - // create the Mail object - $mail = Mail::create($values)->read(['id'])->first(true); - // create JSON data (append newly created object ID) - $values = $email->setId($mail['id'])->toArray(); // convert to JSON - $data = json_encode($values, JSON_PRETTY_PRINT); + $data = json_encode($mail, JSON_PRETTY_PRINT); if($data === false) { - throw new \Exception('failed_json_conversion', QN_ERROR_UNKNOWN); + throw new \Exception('failed_json_conversion', EQ_ERROR_UNKNOWN); } // export to outbox $filename = self::MESSAGE_FOLDER.'/'.md5(time().'-'.$email->subject.'-'.$email->to); if(file_put_contents($filename, $data) === false) { - throw new \Exception('failed_file_creation', QN_ERROR_UNKNOWN); + throw new \Exception('failed_file_creation', EQ_ERROR_UNKNOWN); } return $mail['id']; } - public static function isQueued(int $message_id) { + /** + * Instantly send a message (skip outbox). + * + * @param Email $email Email message to be sent. + * @param string $object_class Class of the object associated with the sending (optional). + * @param string $object_id Identifier of the object associated with the sending (optional). + * + * @return int Upon success, this method returns the id of the created `core\Mail` object created. + * + * @throws \Exception This method raises an Exception in case of error. + */ + public static function send(Email $email, string $object_class = '', int $object_id = 0): int { + $mail = self::createMail($email, $object_class, $object_id); + + try { + // get SMTP mailer + $mailer = self::provideMailer(); + if(!$mailer) { + throw new \Exception('failed_creating_mailer', EQ_ERROR_UNKNOWN); + } + // build envelope + $envelope = self::createEnvelope($mail); + if(!$envelope) { + throw new \Exception('failed_creating_envelope', EQ_ERROR_UNKNOWN); + } + // send email message + if($mailer->send($envelope) == 0) { + throw new \Exception('failed_sending_email', EQ_ERROR_UNKNOWN); + } + // update the core\Mail object status + self::id($mail['id'])->update(['status' => 'sent', 'response_status' => 250]); + } + catch(\Exception $e) { + self::id($mail['id'])->update(['status' => 'failing', 'response_status' => 500, 'response' => $e->getMessage()]); + throw new \Exception($e->getMessage(), $e->getCode()); + } + + return $mail['id']; + } + + public static function isQueued(int $id): bool { $files = scandir(self::MESSAGE_FOLDER); foreach($files as $file) { // skip special files @@ -165,7 +184,7 @@ public static function isQueued(int $message_id) { // ignore invalid messages continue; } - if($message['id'] == $message_id) { + if($message['id'] == $id) { return true; } } @@ -173,17 +192,76 @@ public static function isQueued(int $message_id) { } /** - * Send all messages currently in the outbox. + * Send a batch of messages that are queued in the outbox. * */ public static function flush() { - // load dependencies - if(!file_exists(QN_BASEDIR.'/vendor/swiftmailer/swiftmailer/lib/swift_required.php')) { - throw new \Exception("missing_dependency", QN_ERROR_INVALID_CONFIG); + + // retrieve messages from files under `/spool` + $queue = self::fetchQueue(); + + // get SMTP mailer + $mailer = self::provideMailer(); + if(!$mailer) { + throw new \Exception('failed_creating_mailer', EQ_ERROR_UNKNOWN); } - require_once(QN_BASEDIR.'/vendor/swiftmailer/swiftmailer/lib/swift_required.php'); + // #todo - store as setting + $max = 10; + $i = 0; + // loop through messages + foreach($queue as $file => $message) { + try { + // prevent handling more than $max messages (successfully sent) + if($i > $max) { + break; + } + + if(isset($message['id'])) { + $mailMessage = self::id($message['id'])->read(['status'])->first(); + // prevent re-sending already sent messages + if($mailMessage['status'] == 'sent') { + unlink(self::MESSAGE_FOLDER.'/'.$file); + continue; + } + } + + $envelope = self::createEnvelope($message); + + if(!$envelope) { + throw new \Exception('failed_creating_envelope', EQ_ERROR_UNKNOWN); + } + + // send email + if($mailer->send($envelope) == 0) { + throw new \Exception('failed_sending_email', EQ_ERROR_UNKNOWN); + } + + // upon successful sending, remove the mail from the outbox + $filename = self::MESSAGE_FOLDER.'/'.$file; + unlink($filename); + + // if the message is linked to a core\Mail object, update the latter's status + if(isset($message['id'])) { + self::id($message['id'])->update(['status' => 'sent', 'response_status' => 250]); + } + + // prevent flooding the SMTP (wait 100 ms) + usleep(100 *1000); + ++$i; + } + catch(\Exception $e) { + // sending failed + // if the message is linked to a core\Mail object, update the latter's status + if(isset($message['id'])) { + self::id($message['id'])->update(['status' => 'failing', 'response_status' => 500, 'response' => $e->getMessage()]); + } + // #todo : add support for choosing what to do upon failure (retry, delete, notify) + } + } + } + private static function fetchQueue() { // load pending messages by reading all files in `$messages_folder` (outbox) directory $queue = []; $files = scandir(self::MESSAGE_FOLDER); @@ -212,136 +290,158 @@ public static function flush() { } $queue[$file] = $message; } + return $queue; + } + + /** + * Create a Mail object and return an associative array representation of it. + * The Mail object is attached to an object, if provided ($object_class::$object_id). + */ + private static function createMail(Email $email, string $object_class = '', int $object_id = 0): array { + $values = [ + 'to' => $email->to, + 'cc' => implode(',', (array) $email->cc), + 'bcc' => implode(',', (array) $email->bcc), + 'subject' => $email->subject, + // #memo - utf8mb4 chars should be removed if DB charset does not support it + // 'body' => preg_replace('/(?:\xF0[\x90-\xBF][\x80-\xBF]{2} | [\xF1-\xF3][\x80-\xBF]{3} | \xF4[\x80-\x8F][\x80-\xBF]{2})/xs', '', $email->body), + 'body' => $email->body, + 'attachments' => '', + 'object_class' => $object_class, + 'object_id' => $object_id + ]; + + if(isset($email->reply_to) && !empty($email->reply_to)) { + $values['reply_to'] = $email->reply_to; + } + // extract attachment names, if any + if(count($email->attachments)) { + $attachments = array_map(function ($a) {return $a->name;}, $email->attachments); + $values['attachments'] = implode("\n", $attachments); + } - // setup SMTP settings - $transport = new \Swift_SmtpTransport( - constant('EMAIL_SMTP_HOST'), - constant('EMAIL_SMTP_PORT'), - (defined('EMAIL_SMTP_ENCRYPT') && in_array(constant('EMAIL_SMTP_ENCRYPT'), ['tls', 'ssl']))?constant('EMAIL_SMTP_ENCRYPT'):null - ); - - $transport - ->setUsername(constant('EMAIL_SMTP_ACCOUNT_USERNAME')) - ->setPassword(constant('EMAIL_SMTP_ACCOUNT_PASSWORD')); - - if(defined('EMAIL_SMTP_ENCRYPT') && in_array(constant('EMAIL_SMTP_ENCRYPT'), ['tls', 'ssl'])) { - $transport->setStreamOptions([ - 'ssl' => [ - 'allow_self_signed' => true, - 'verify_peer' => false - ] - ]); + // create the core\Mail object + $mail = Mail::create($values)->read(['id'])->first(true); + if(!$mail) { + throw new \Exception('failed_creating_mail', EQ_ERROR_UNKNOWN); } - // setup SMTP settings - $mailer = new \Swift_Mailer($transport); + // export resulting message as array + return $email->setId($mail['id'])->toArray(); + } - // #todo - store as setting - $max = 10; - $i = 0; - // loop through messages - foreach($queue as $file => $message) { - // prevent handling more than $max messages (successfully sent) - if($i > $max) { - break; - } + private static function createEnvelope($message): \Swift_Message { + $envelope = null; - if(isset($message['id'])) { - $mailMessage = self::id($message['id'])->read(['status'])->first(); - // prevent re-sending already sent messages - if($mailMessage['status'] == 'sent') { - unlink(self::MESSAGE_FOLDER.'/'.$file); - continue; - } + try { + // load dependencies + if(!file_exists(EQ_BASEDIR.'/vendor/swiftmailer/swiftmailer/lib/swift_required.php')) { + throw new \Exception("missing_dependency", EQ_ERROR_INVALID_CONFIG); } + require_once(EQ_BASEDIR.'/vendor/swiftmailer/swiftmailer/lib/swift_required.php'); $body = (isset($message['body']))?$message['body']:''; $subject = (isset($message['subject']))?$message['subject']:''; - try { - if(!isset($message['to']) || strlen($message['to']) <= 0) { - throw new \Exception('empty_recipient', QN_ERROR_INVALID_PARAM); - } + if(!isset($message['to']) || strlen($message['to']) <= 0) { + throw new \Exception('empty_recipient', EQ_ERROR_INVALID_PARAM); + } - $envelope = new \Swift_Message(); - // set sender and recipients - $envelope - ->setTo($message['to']) - ->setCc($message['cc']) - ->setBcc($message['bcc']) - ->setFrom([constant('EMAIL_SMTP_ACCOUNT_EMAIL') => constant('EMAIL_SMTP_ACCOUNT_DISPLAYNAME')]); + $envelope = new \Swift_Message(); + // set sender and recipients + $envelope + ->setTo($message['to']) + ->setCc($message['cc']) + ->setBcc($message['bcc']) + ->setFrom([constant('EMAIL_SMTP_ACCOUNT_EMAIL') => constant('EMAIL_SMTP_ACCOUNT_DISPLAYNAME')]); - if(isset($message['reply_to']) && strlen($message['reply_to']) > 0) { - $envelope->setReplyTo($message['reply_to']); - } + if(isset($message['reply_to']) && strlen($message['reply_to']) > 0) { + $envelope->setReplyTo($message['reply_to']); + } - // add subject - $envelope->setSubject($subject); - - // process body according to content type - if(isset($message['content-type']) && $message['content-type'] == 'text/html') { - $envelope->setContentType('text/html'); - // handle embedded images, if any - $body = preg_replace_callback('/(src="?)([^"]*)("?)/i', - function ($matches) use (&$envelope) { - $cid = $matches[2]; - if(substr($cid, 4, 1) == ':') { - list($scheme, $data) = explode(':', $cid); - if($scheme == 'data') { - list($content_type, $data) = explode(';', $data); - list($encoding, $raw) = explode(',', $data); - if($encoding == 'base64') { - $raw = base64_decode($raw); - } - list($type, $extension) = explode('/', $content_type); - $img = new \Swift_Image($raw, 'img_'.rand(1,999).'.'.$extension , $content_type); - $img->setDisposition('inline'); - $cid = $envelope->embed($img); + // add subject + $envelope->setSubject($subject); + + // process body according to content type + if(isset($message['content-type']) && $message['content-type'] == 'text/html') { + $envelope->setContentType('text/html'); + // handle embedded images, if any + $body = preg_replace_callback('/(src="?)([^"]*)("?)/i', + function ($matches) use (&$envelope) { + $cid = $matches[2]; + if(substr($cid, 4, 1) == ':') { + list($scheme, $data) = explode(':', $cid); + if($scheme == 'data') { + list($content_type, $data) = explode(';', $data); + list($encoding, $raw) = explode(',', $data); + if($encoding == 'base64') { + $raw = base64_decode($raw); } + list($type, $extension) = explode('/', $content_type); + $img = new \Swift_Image($raw, 'img_'.rand(1,999).'.'.$extension , $content_type); + $img->setDisposition('inline'); + $cid = $envelope->embed($img); } - return $matches[1].$cid.$matches[3]; - }, - $body); - } - - // add body - $envelope->setBody($body); + } + return $matches[1].$cid.$matches[3]; + }, + $body); + } - // add attachments - if(isset($message['attachments']) && count($message['attachments'])) { - foreach($message['attachments'] as $key => $attachment) { - $envelope->attach(new \Swift_Attachment($attachment['data'], $attachment['name'], $attachment['type'])); - } - } + // add body + $envelope->setBody($body); - // send email - if($mailer->send($envelope) == 0) { - throw new \Exception('recipients_unreachable', QN_ERROR_UNKNOWN); + // add attachments + if(isset($message['attachments']) && count($message['attachments'])) { + foreach($message['attachments'] as $key => $attachment) { + $envelope->attach(new \Swift_Attachment($attachment['data'], $attachment['name'], $attachment['type'])); } + } + } + catch(\Exception $e) { + trigger_error("ORM::createEnvelope: ".$e->getMessage(), QN_REPORT_ERROR); + } - // upon successful sending, remove the mail from the outbox - $filename = self::MESSAGE_FOLDER.'/'.$file; - unlink($filename); + return $envelope; + } - // if the message is linked to a core\Mail object, update the latter's status - if(isset($message['id'])) { - self::id($message['id'])->update(['status' => 'sent', 'response_status' => 250]); - } + private static function provideMailer() { + $mailer = null; - // prevent flooding the SMTP (wait 100 ms) - usleep(100 *1000); - ++$i; + try { + // load dependencies + if(!file_exists(EQ_BASEDIR.'/vendor/swiftmailer/swiftmailer/lib/swift_required.php')) { + throw new \Exception("missing_dependency", EQ_ERROR_INVALID_CONFIG); } - catch(\Exception $e) { - // sending failed - // if the message is linked to a core\Mail object, update the latter's status - if(isset($message['id'])) { - self::id($message['id'])->update(['status' => 'failing', 'response_status' => 500, 'response' => $e->getMessage()]); - } - // #todo : add support for choosing what to do upon failure (retry, delete, notify) + require_once(EQ_BASEDIR.'/vendor/swiftmailer/swiftmailer/lib/swift_required.php'); + + // setup SMTP settings + $transport = new \Swift_SmtpTransport( + constant('EMAIL_SMTP_HOST'), + constant('EMAIL_SMTP_PORT'), + (defined('EMAIL_SMTP_ENCRYPT') && in_array(constant('EMAIL_SMTP_ENCRYPT'), ['tls', 'ssl']))?constant('EMAIL_SMTP_ENCRYPT'):null + ); + + $transport + ->setUsername(constant('EMAIL_SMTP_ACCOUNT_USERNAME')) + ->setPassword(constant('EMAIL_SMTP_ACCOUNT_PASSWORD')); + + if(defined('EMAIL_SMTP_ENCRYPT') && in_array(constant('EMAIL_SMTP_ENCRYPT'), ['tls', 'ssl'])) { + $transport->setStreamOptions([ + 'ssl' => [ + 'allow_self_signed' => true, + 'verify_peer' => false + ] + ]); } + + $mailer = new \Swift_Mailer($transport); + } + catch(\Exception $e) { + // #todo - log error } - } + return $mailer; + } -} \ No newline at end of file +} diff --git a/packages/core/classes/setting/Setting.class.php b/packages/core/classes/setting/Setting.class.php index 93ef9ecce..32cbe756e 100644 --- a/packages/core/classes/setting/Setting.class.php +++ b/packages/core/classes/setting/Setting.class.php @@ -191,9 +191,11 @@ public function getUnique() { * * @return mixed Returns the value of the target setting or null if the setting parameter is not found. The type of the returned var depends on the setting's `type` field. */ - public static function get_value(string $package, string $section, string $code, $default=null, array $selector=[], string $lang='en') { + public static function get_value(string $package, string $section, string $code, $default=null, array $selector=[], string $lang=null) { $result = $default; + $lang = $lang ?? constant('DEFAULT_LANG'); + // #memo - we use a dedicated cache since several o2m fields are involved and we want to prevent loading the same value multiple times in a same thread $index = $package.'.'.$section.'.'.$code.'.'.implode('.', array_values($selector)).'.'.$lang; if(!isset($GLOBALS['_equal_core_setting_cache'])) { @@ -261,10 +263,12 @@ public static function get_value(string $package, string $section, string $code, * * @return void */ - public static function set_value(string $package, string $section, string $code, $value, array $selector=[], $lang='en') { + public static function set_value(string $package, string $section, string $code, $value, array $selector=[], $lang=null) { $providers = \eQual::inject(['orm']); $om = $providers['orm']; + $lang = $lang ?? constant('DEFAULT_LANG'); + $sections_ids = $om->search(SettingSection::getType(), ['code', '=', $section]); if(!count($sections_ids)) { // section does not exist yet diff --git a/packages/core/data/config/usages.php b/packages/core/data/config/usages.php index 00cc868f2..252019a2a 100644 --- a/packages/core/data/config/usages.php +++ b/packages/core/data/config/usages.php @@ -20,12 +20,306 @@ */ list($context) = [$providers['context']]; -if(!file_exists(QN_BASEDIR."/config/usages.json")) { - throw new Exception("missing_usages_file", QN_ERROR_UNKNOWN); -} - -$content = file_get_contents(QN_BASEDIR."/config/usages.json"); +$schema = '{ + "string" : { + "application" : { + "subusages" : { + "json" : {}, + "xml" : {}, + "yaml" : {} + } + }, + "text" : { + "subusages" : { + "plain" : { + "variations" : { + "short" : { + "length_free" : false, + "boundary" : false + }, + "small" : { + "length_free" : false, + "boundary" : false + }, + "medium": { + "length_free" : false, + "boundary" : false + }, + "long" : { + "length_free" : false, + "boundary" : false + } + } + }, + "xml" : {}, + "html" : {}, + "markdown" : {}, + "wiki" : {}, + "json" : {} + }, + "length_free" : true, + "boundary" : true + }, + "uri" : { + "subusages" : { + "url" : { + "variations" : { + "mailto": {}, + "payto" : {}, + "tel" : {}, + "http" : {}, + "ftp" : {} + } + }, + "urn" : { + "variations" : { + "iban": {}, + "isbn" : { + "length" : [ + 10, + 13 + ] + }, + "ean" : { + "length" : [ + 13 + ] + } + } + } + } + }, + "email" : {}, + "language" : { + "subusages" : { + "iso-639" : { + "length" : [ + 2, + 3 + ] + } + } + }, + "country" : { + "subusages" : { + "iso-3166" : { + "length" : [ + 2, + 3 + ] + } + } + }, + "password" : {}, + "coordinate" : { + "subusages" : { + "latitude" : { + "variations" : { + "decimal" : {}, + "dms" : {} + } + }, + "longitude" : { + "variations" : { + "decimal" : {}, + "dms" : {} + } + } + } + }, + "currency" : { + "subusages" : { + "iso-4217" : { + "variations" : { + "alpha" : {}, + "numeric" : {} + } + } + } + }, + "hash" : { + "subusages" : { + "md" : { + "variations" : { + "4" : { + "length" : [ + 32 + ] + }, + "5" : { + "length" : [ + 32 + ] + }, + "6" : { + "length" : [ + 64 + ] + } + } + }, + "sha" : { + "variations" : { + "1" : { + "length" : [ + 40 + ] + }, + "256" : { + "length" : [ + 64 + ] + }, + "512" : { + "length" : [ + 128 + ] + } + } + } + } + }, + "color" : { + "subusages" : { + "css" : {}, + "rgb" : {}, + "rgba" : {}, + "hexadecimal" : {} + } + }, + "orm" : { + "subusages" : { + "relationship" : { + "many2one" : {}, + "one2many" : {}, + "one2one" : {} + }, + "type" : { + "variations" : { + "float" : {}, + "string" : {}, + "binary" : {}, + "integer" : {}, + "time" : {}, + "datetime" : {}, + "date" : {} + } + }, + "entity" : {}, + "package" : {} + } + } + }, + "boolean" : { + "number" : { + "subusages" : { + "boolean" : {} + }, + "length_free" : true, + "boundary" : true + } + }, + "integer" : { + "number" : { + "subusages" : { + "natural" : {}, + "integer" : { + "variations" : { + "decimal" : {}, + "hexadecimal" : {}, + "octal" : {} + } + } + }, + "length_free" : true, + "boundary" : true + }, + "orm" : { + "subusages" : { + "object_id" : {} + } + } + }, + "float" : { + "amount" : { + "subusages" : { + "money" : { + "length_dim" : 2 + }, + "percent" : {}, + "rate" : {} + } + }, + "number" : { + "subusages" : { + "real" : { + "length_dim" : 2 + } + } + }, + "length_free" : true, + "boundary" : true + }, + "binary" : { + "image" : { + "subusages" : { + "jpeg" : {}, + "gif" : {}, + "png" : {}, + "tiff" : {}, + "wepb" : {} + } + }, + "application" : { + "subusages" : { + "pdf" : {}, + "zip" : {}, + "excel" : { + "variations" : { + "xlsx" : {}, + "xls" : {} + } + }, + "word" : { + "variations" : { + "doc" : {}, + "docx" : {} + } + }, + "powerpoint" : { + "ppt" : {}, + "pptx" : {} + } + } + }, + "audio" : { + "subusages" : { + "aac" : {}, + "webm" : {} + } + }, + "video" : { + "x-msvideo" : {}, + "webm" : {} + } + }, + "array" : { + "array" : { + "subusages" : { + "plain" : { + "boundary" : true + }, + "domain" : { + "boundary" : false + }, + "clause" : { + "boundary" : false + } + } + } + } +}'; $context->httpResponse() - ->body($content) + ->body($schema) ->send(); diff --git a/packages/core/data/envinfo.php b/packages/core/data/envinfo.php index 55cc0a4a1..ec8b5cbf6 100644 --- a/packages/core/data/envinfo.php +++ b/packages/core/data/envinfo.php @@ -1,10 +1,9 @@ - Some Rights Reserved, Cedric Francoys, 2010-2021 + Some Rights Reserved, Cedric Francoys, 2010-2024 Licensed under GNU LGPL 3 license */ - use core\setting\SettingValue; list( $params, $providers ) = eQual::announce([ @@ -43,7 +42,6 @@ "rest_api_url" => constant('REST_API_URL'), "lang" => constant('APP_DEFAULT_LANG'), "locale" => constant('L10N_LOCALE'), - "version" => constant('EQ_VERSION'), "company_name" => constant('ORG_NAME'), "company_url" => constant('ORG_URL'), "app_name" => constant('APP_NAME'), @@ -55,6 +53,9 @@ // append settings values if request is made by an authenticated user if($user_id) { + // disclose version only to authenticated users + $envinfo["version"] = constant('EQ_VERSION'); + // 1) read global settings $settings = SettingValue::search(['user_id', '=', 0])->read(['name', 'value'])->get(); diff --git a/packages/core/data/user/groups.php b/packages/core/data/user/groups.php index 992493015..389186b0c 100644 --- a/packages/core/data/user/groups.php +++ b/packages/core/data/user/groups.php @@ -1,13 +1,13 @@ - Some Rights Reserved, Cedric Francoys, 2010-2021 + This file is part of the eQual framework + Some Rights Reserved, Cedric Francoys, 2010-2024 Licensed under GNU LGPL 3 license */ use core\User; use core\Group; -list($params, $providers) = announce([ +list($params, $providers) = eQual::announce([ 'description' => 'List all groups of a given user.', 'response' => [ 'content-type' => 'application/json', @@ -48,9 +48,9 @@ $groups = Group::ids($groups_ids)->read(['id', 'name'])->get(); -$groups_txt = array_map(function($a) {return $a['name'];}, $groups); +$groups_names = array_map(function($a) {return $a['name'];}, $groups); $context->httpResponse() ->status(200) - ->body(['result' => implode(', ', $groups_txt)]) - ->send(); \ No newline at end of file + ->body($groups_names) + ->send(); diff --git a/packages/core/i18n/en/mail_test.html b/packages/core/i18n/en/mail_test.html new file mode 100644 index 000000000..bea61de8e --- /dev/null +++ b/packages/core/i18n/en/mail_test.html @@ -0,0 +1,3 @@ +

+Test email was successfully sent from {{ url }} ! +

\ No newline at end of file diff --git a/packages/core/i18n/fr/mail_test.html b/packages/core/i18n/fr/mail_test.html new file mode 100644 index 000000000..db417532a --- /dev/null +++ b/packages/core/i18n/fr/mail_test.html @@ -0,0 +1,3 @@ +

+Message email de test envoyé avec succès depuis {{ url }} ! +

\ No newline at end of file diff --git a/packages/core/init/assets/img/equal_logo.png b/packages/core/init/assets/img/equal_logo.png new file mode 100644 index 000000000..4a71538fa Binary files /dev/null and b/packages/core/init/assets/img/equal_logo.png differ diff --git a/packages/core/init/assets/img/equal_summary.png b/packages/core/init/assets/img/equal_summary.png new file mode 100644 index 000000000..385b06f68 Binary files /dev/null and b/packages/core/init/assets/img/equal_summary.png differ diff --git a/packages/core/views/Assignment.form.default.json b/packages/core/views/Assignment.form.default.json new file mode 100644 index 000000000..062552554 --- /dev/null +++ b/packages/core/views/Assignment.form.default.json @@ -0,0 +1,72 @@ +{ + "name": "Assignment", + "description": "Role assignment granted to a single user.", + "layout": { + "groups": [ + { + "sections": [ + { + "rows": [ + { + "columns": [ + { + "width": "50%", + "align": "left", + "items": [ + { + "type": "field", + "value": "id", + "width": "33%", + "readonly": true + }, + { + "type": "label", + "value": "", + "width": "66%" + }, + { + "type": "field", + "value": "object_class", + "width": "75%" + }, + { + "type": "field", + "value": "object_id", + "width": "25%" + }, + { + "type": "field", + "value": "password", + "width": "50%", + "help": "Enter a new value to update." + } + ] + }, + { + "width": "25%", + "items": [] + }, + { + "width": "25%", + "items": [ + { + "type": "field", + "value": "user_id", + "width": "100%" + }, + { + "type": "field", + "value": "role", + "width": "100%" + } + ] + } + ] + } + ] + } + ] + } + ] + } +} diff --git a/packages/core/views/Assignment.list.default.json b/packages/core/views/Assignment.list.default.json new file mode 100644 index 000000000..af6c5e114 --- /dev/null +++ b/packages/core/views/Assignment.list.default.json @@ -0,0 +1,36 @@ +{ + "name": "Assignments", + "description": "List of existing Role assignments granted to users.", + "layout": { + "items": [ + { + "type": "field", + "value": "id", + "width": "10%" + }, + { + "type": "field", + "value": "object_class", + "width": "20%", + "widget": { + "sortable": true + } + }, + { + "type": "field", + "value": "object_id", + "width": "15%" + }, + { + "type": "field", + "value": "user_id", + "width": "15%" + }, + { + "type": "field", + "value": "role", + "width": "25%" + } + ] + } +} \ No newline at end of file diff --git a/packages/core/views/Group.form.default.json b/packages/core/views/Group.form.default.json index c393c1ca1..9d47f76f4 100644 --- a/packages/core/views/Group.form.default.json +++ b/packages/core/views/Group.form.default.json @@ -53,7 +53,8 @@ "value": "description", "width": "100%", "widget": { - "type": "text" + "type": "text", + "height": 100 } } ] diff --git a/public/welcome/index.html b/public/welcome/index.html index 8f00a1507..1e1f08158 100644 --- a/public/welcome/index.html +++ b/public/welcome/index.html @@ -105,7 +105,7 @@ $( document ).ready(function() { - setPrompt('user', 'equal.run'); + setPrompt('user', 'equal.local'); var lines = $('textarea').val().split('\n'); @@ -131,12 +131,12 @@
-
+
@@ -155,13 +155,13 @@ [ { "id": 1, - "name": "root@host.local", + "name": "root@equal.local", "state": "instance", "modified": "2023-07-31T09:01:09+00:00" }, { "id": 2, - "name": "root@equal.run", + "name": "user@equal.local", "state": "instance", "modified": "2023-07-31T11:13:26+00:00" } @@ -169,8 +169,8 @@ $ ./equal.run --get=model_read --entity='core\User' --ids=[2] --fields='{firstname,lastname,groups_ids:{id,name}}' [ { - "firstname": "C\u00e9dric", - "lastname": "Fran\u00e7oys", + "firstname": "First", + "lastname": "USER", "groups_ids": [ { "id": 2, @@ -179,7 +179,7 @@ "modified": "2023-07-31T00:00:00+00:00" } ], - "name": "cedric@equal.run", + "name": "user@equal.local", "id": 2, "state": "instance", "modified": "2023-07-31T11:13:26+00:00" diff --git a/run.php b/run.php index e68b72252..cab99b372 100644 --- a/run.php +++ b/run.php @@ -3,7 +3,7 @@ * This file is part of the eQual framework. * https://github.com/equalframework/equal * -* Some Rights Reserved, Cedric Francoys, 2010-2021 +* Some Rights Reserved, Cedric Francoys, 2010-2024 * Licensed under GNU LGPL 3 license * * This program is free software: you can redistribute it and/or modify @@ -19,15 +19,12 @@ * You should have received a copy of the GNU Lesser General Public License * along with this program. If not, see . */ - -use equal\auth\AuthenticationManager; use equal\error\Reporter; use equal\php\Context; use equal\route\Router; use equal\services\Container; /* - This is the root entry point and acts as dispatcher. Its role is to set up the context and handle the client request. Dispatching consists of resolving targeted operation and include related script file. @@ -40,53 +37,44 @@ /?get=resiway_tests&id=1&test=2 PHP run('get', 'utils_sql-schema', ['package'=>'core']); - */ // Bootstrap the library holding system constants and functions definitions and autoload support. $bootstrap = dirname(__FILE__).'/eq.lib.php'; if( (include($bootstrap)) === false ) { - die('eQual lib is missing.'); + die('Great Scott! eQual lib is missing.'); +} +// remove PHP signature in prod +if(constant('ENV_MODE') == 'production') { + header_remove('x-powered-by'); } -try { - // remove PHP signature in prod - if(constant('ENV_MODE') == 'production') { - header_remove('x-powered-by'); - } +// get PHP context +/** @var \equal\php\Context */ +$context = Context::getInstance(); - // get PHP context - $context = Context::getInstance(); +try { + // 1) retrieve current HTTP context // fetch current HTTP request from context $request = $context->getHttpRequest(); - + // retrieve current user $auth = Container::getInstance()->get('auth'); - $user_id = $auth->userId(); - // keep track of the access in the log - Reporter::errorHandler(EQ_REPORT_SYSTEM, "AAA::".json_encode(['type' => 'auth', 'user_id' => $user_id])); - - $ip_address = $request->getHeader('X-Forwarded-For'); - - $access = Container::getInstance()->get('access'); - - // #memo - this call might be made while database is not yet present - if(php_sapi_name() != 'cli' && !$access->isRequestCompliant($user_id, $ip_address)) { - Reporter::errorHandler(EQ_REPORT_SYSTEM, "AAA::".json_encode(['type' => 'policy', 'status' => 'denied'])); - throw new Exception("Request rejected by Security Policies", EQ_ERROR_NOT_ALLOWED); - } - + Reporter::errorHandler(EQ_REPORT_SYSTEM, "AAA::".json_encode(['type' => 'auth', 'user_id' => $auth->userId(), 'ip_address' => $request->getHeaders()->getIpAddress()])); // get HTTP method of current request $method = $request->getMethod(); // get HttpUri object (@see equal\http\HttpUri class for URI structure) $uri = $request->getUri(); // retrieve additional info from URI list($path, $route) = [ - $uri->getPath(), // current URI path - $uri->get('route') // 'route' param from URI query string, if any + $uri->getPath(), + $uri->get('route') ]; + + // 2) handle routing, if required (i.e. URL to operation translation) + // adjust path to route param, if set if($route) { $parts = explode(':', $route); @@ -101,15 +89,16 @@ $request->del('route'); } - // 2) handle routing, if required (i.e. URL to operation translation) - // if routing is required - if( strlen($path) > 1 && !in_array(basename($path), [ - 'index.php', // HTTP request - 'run.php', // CLI - 'equal.php' // integration with other frameworks relying on `index.php` (e.g. WP) + if( strlen($path) > 1 + && !in_array(basename($path), [ + // HTTP request + 'index.php', + // CLI + 'run.php', + // HTTP request with another framework relying on `index.php` (e.g. WP) + 'equal.php' ]) ) { - $router = Router::getInstance(); // add routes providers according to current request $router->add(QN_BASEDIR.'/config/routing/*.json'); @@ -189,7 +178,7 @@ Reporter::errorHandler(EQ_REPORT_SYSTEM, "NET::".json_encode([ 'start' => $_SERVER["REQUEST_TIME_FLOAT"], 'end' => microtime(true), - 'ip' => $ip_address + 'ip' => $request->getHeaders()->getIpAddress() ]) ); } @@ -205,7 +194,7 @@ if($error_code != 0) { Reporter::handleThrowable($e); // retrieve info from HTTP request (we don't ask for $context->httpResponse() since it might have raised the current exception) - $request = $context->httpRequest(); + $request = $context->getHttpRequest(); $request_method = $request->getMethod(); $request_headers = $request->getHeaders(true); // get HTTP status code according to raised exception @@ -250,7 +239,7 @@ Reporter::errorHandler(EQ_REPORT_SYSTEM, "NET::".json_encode([ 'start' => $_SERVER["REQUEST_TIME_FLOAT"], 'end' => microtime(true), - 'ip' => $_SERVER['HTTP_X_FORWARDED_FOR'] ?? ( $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1' ) + 'ip' => $request->getHeaders()->getIpAddress() ]) ); // an exception with code 0 is an explicit request to halt process with no error