From bf1330ede0def7691b1b9d85848393cea464b827 Mon Sep 17 00:00:00 2001 From: Rijk van Zanten Date: Mon, 29 Jul 2019 11:32:08 -0400 Subject: [PATCH] Release 2.3.1 (#1158) * Parent + Nested validation changes (#1138) * #1121 (#1126) * Public Role : UserId should be set 0 instead of null * Change : invalid token * Issue Fix #1109 (#1146) * Added file support for 7.0 (Explained) (#1124) * Bump version to 2.3.0 (#1120) * Added file support for 7.0 (Explained) Having `public` in front of `const` completely breaks the application for `PHP 7.0` usage, which broke everything when I pulled origin. Though I understand `PHP 7.0` isn't officially supported, and that `PHP 7.1+` is, there is no reason to use public alongside const as the default visibility of class constants are public. We might as well provide support where possible if it doesn't hurt. Explained here: https://stackoverflow.com/a/51568547 * Issue Fix #1114 (#1128) * Issue Fix #1114 * Change exception message * Update .gitignore (#1129) * Bump version to 2.3.0 (#1120) * Update .gitignore * Update .gitignore * Update .gitignore * Update .gitignore * Issue Fix #1125 (#1134) * Issue Fix #1131 (#1135) * create thumb for pdf if imagick is available (#1123) * Bump version to 2.3.0 (#1120) * create thumb for pdf if imagick is available * Issue Fix #1109 * Add Special characters in the radom string generator * Issue Fix #1109 * Remove other option * Imagick changes * Issue Fix #1148 (#1152) * Fix 1149 (#1156) * Process relation & non relatinal fields sequentially to solve logical operator issue * Process relation & non relatinal fields sequentially to solve logical operator issue * Fixed namespace of InvalidLoggerConfigurationException (#1153) * Bump version to v2.3.1 --- .gitignore | 4 + ...0232_password_validation_setting_field.php | 29 +++ ...26064001_update_note_for_default_limit.php | 47 +++++ package.json | 2 +- src/core/Directus/Application/Application.php | 2 +- .../Application/CoreServicesProvider.php | 4 +- .../Middleware/AuthenticationMiddleware.php | 20 +- src/core/Directus/Config/Schema/Types.php | 8 +- src/core/Directus/Console/Common/User.php | 9 + .../TableGateway/RelationalTableGateway.php | 191 ++++++++++++++++-- src/core/Directus/Filesystem/Files.php | 22 +- src/core/Directus/Filesystem/Thumbnail.php | 5 +- src/core/Directus/Filesystem/Thumbnailer.php | 20 +- .../InvalidLoggerConfigurationException.php | 2 +- src/core/Directus/Services/ItemsService.php | 61 +++++- src/core/Directus/Services/UsersService.php | 10 + src/core/Directus/Util/StringUtils.php | 2 +- src/endpoints/Settings.php | 7 +- src/helpers/file.php | 4 +- 19 files changed, 386 insertions(+), 63 deletions(-) create mode 100644 migrations/upgrades/schemas/20190722110232_password_validation_setting_field.php create mode 100644 migrations/upgrades/schemas/20190726064001_update_note_for_default_limit.php diff --git a/.gitignore b/.gitignore index 2e4b9c1746..731ff47a3a 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,10 @@ composer.phar composer.lock /vendor +# Exclude env vars and custom deployment scripts +.env +deploy.* + # Ignore configuration files /config/* !/config/migrations.php diff --git a/migrations/upgrades/schemas/20190722110232_password_validation_setting_field.php b/migrations/upgrades/schemas/20190722110232_password_validation_setting_field.php new file mode 100644 index 0000000000..1c3abf7b3a --- /dev/null +++ b/migrations/upgrades/schemas/20190722110232_password_validation_setting_field.php @@ -0,0 +1,29 @@ +getAdapter()->getConnection(); + + $fieldObject = [ + 'field' => 'password_policy', + 'type' => 'string', + 'note' => 'Weak : Minimum length 8; Strong : 1 small-case letter, 1 capital letter, 1 digit, 1 special character and the length should be minimum 8', + 'interface' => 'dropdown', + 'options' => ['choices' => ['' => 'None', '/^.{8,}$/' => 'Weak', '/(?=^.{8,}$)(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[!@#$%^&*()_+}{\';\'?>.<,])(?!.*\s).*$/' => 'Strong']] + ]; + $collection = 'directus_settings'; + $checkSql = sprintf('SELECT 1 FROM `directus_fields` WHERE `collection` = "%s" AND `field` = "%s";', $collection, $fieldObject['field']); + $result = $this->query($checkSql)->fetch(); + if (!$result) { + $insertSqlFormat = "INSERT INTO `directus_fields` (`collection`, `field`, `type`, `interface`, `options`, `note`) VALUES ('%s', '%s', '%s', '%s' , %s, '%s');"; + $insertSql = sprintf($insertSqlFormat, $collection, $fieldObject['field'], $fieldObject['type'], $fieldObject['interface'], $conn->quote(json_encode($fieldObject['options'])) , $fieldObject['note']); + $this->execute($insertSql); + } + + } +} diff --git a/migrations/upgrades/schemas/20190726064001_update_note_for_default_limit.php b/migrations/upgrades/schemas/20190726064001_update_note_for_default_limit.php new file mode 100644 index 0000000000..aee98ebb1c --- /dev/null +++ b/migrations/upgrades/schemas/20190726064001_update_note_for_default_limit.php @@ -0,0 +1,47 @@ +execute(\Directus\phinx_update( + $this->getAdapter(), + 'directus_fields', + [ + 'note' => 'The color that best fits your brand.' + ], + ['collection' => 'directus_settings', 'field' => 'color'] + )); + + $this->execute(\Directus\phinx_update( + $this->getAdapter(), + 'directus_fields', + [ + 'note' => 'Default max amount of items that\'s returned at a time in the API.' + ], + ['collection' => 'directus_settings', 'field' => 'default_limit'] + )); + + $this->execute(\Directus\phinx_update( + $this->getAdapter(), + 'directus_fields', + [ + 'width' => 'half', + ], + ['collection' => 'directus_settings', 'field' => 'password_policy'] + )); + + $this->execute(\Directus\phinx_update( + $this->getAdapter(), + 'directus_fields', + [ + 'width' => 'half', + ], + ['collection' => 'directus_settings', 'field' => 'file_max_size'] + )); + + } +} diff --git a/package.json b/package.json index c1ce309ef3..be08d776b6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@directus/api", "private": true, - "version": "2.3.0", + "version": "2.3.1", "description": "Directus API", "main": "index.js", "repository": "directus/api", diff --git a/src/core/Directus/Application/Application.php b/src/core/Directus/Application/Application.php index c5ce35747c..cd26de8a0c 100644 --- a/src/core/Directus/Application/Application.php +++ b/src/core/Directus/Application/Application.php @@ -13,7 +13,7 @@ class Application extends App * * @var string */ - const DIRECTUS_VERSION = '2.3.0'; + const DIRECTUS_VERSION = '2.3.1'; /** * NOT USED diff --git a/src/core/Directus/Application/CoreServicesProvider.php b/src/core/Directus/Application/CoreServicesProvider.php index 3949a74d9d..19853f19a8 100644 --- a/src/core/Directus/Application/CoreServicesProvider.php +++ b/src/core/Directus/Application/CoreServicesProvider.php @@ -321,12 +321,14 @@ protected function getEmitter() $files = $container->get('files'); $fileData = ArrayUtils::get($data, 'data'); + + $dataInfo = []; if (is_a_url($fileData)) { $dataInfo = $files->getLink($fileData); // Set the URL payload data $payload['data'] = ArrayUtils::get($dataInfo, 'data'); $payload['filename'] = ArrayUtils::get($dataInfo, 'filename'); - } else { + } else if(!is_object($fileData)) { $dataInfo = $files->getDataInfo($fileData); } diff --git a/src/core/Directus/Application/Http/Middleware/AuthenticationMiddleware.php b/src/core/Directus/Application/Http/Middleware/AuthenticationMiddleware.php index 2e55158352..afc7fe72de 100644 --- a/src/core/Directus/Application/Http/Middleware/AuthenticationMiddleware.php +++ b/src/core/Directus/Application/Http/Middleware/AuthenticationMiddleware.php @@ -53,15 +53,7 @@ public function __invoke(Request $request, Response $response, callable $next) if (!is_null($user)) { $rolesIpWhitelist = $this->getUserRolesIPWhitelist($user->getId()); - $permissionsByCollection = $permissionsTable->getUserPermissions($user->getId()); - - // TODO: Adding an user should auto set its ID and GROUP - // TODO: User data should be casted to its data type - // TODO: Make sure that the group is not empty - $acl->setUserId($user->getId()); - $acl->setUserEmail($user->getEmail()); - $acl->setUserFullName($user->get('first_name') . ' ' . $user->get('last_name')); - + $permissionsByCollection = $permissionsTable->getUserPermissions($user->getId()); $hookEmitter->run('auth.success', [$user]); } else { if (is_null($user) && $publicRoleId) { @@ -102,9 +94,13 @@ public function __invoke(Request $request, Response $response, callable $next) $hookEmitter->run('auth.fail', [$exception]); throw $exception; } - - - + + // TODO: Adding an user should auto set its ID and GROUP + // TODO: User data should be casted to its data type + // TODO: Make sure that the group is not empty + $acl->setUserId($user->getId()); + $acl->setUserEmail($user->getEmail()); + $acl->setUserFullName($user->get('first_name') . ' ' . $user->get('last_name')); return $next($request, $response); } diff --git a/src/core/Directus/Config/Schema/Types.php b/src/core/Directus/Config/Schema/Types.php index 0628f2e974..7e21ac34d1 100644 --- a/src/core/Directus/Config/Schema/Types.php +++ b/src/core/Directus/Config/Schema/Types.php @@ -7,8 +7,8 @@ */ interface Types { - public const INTEGER = 'number'; - public const FLOAT = 'float'; - public const STRING = 'string'; - public const BOOLEAN = 'boolean'; + const INTEGER = 'number'; + const FLOAT = 'float'; + const STRING = 'string'; + const BOOLEAN = 'boolean'; } diff --git a/src/core/Directus/Console/Common/User.php b/src/core/Directus/Console/Common/User.php index 84f4eb247e..e3c2aac68a 100644 --- a/src/core/Directus/Console/Common/User.php +++ b/src/core/Directus/Console/Common/User.php @@ -7,6 +7,7 @@ use Directus\Console\Common\Exception\UserUpdateException; use Zend\Db\TableGateway\TableGateway; use Directus\Util\Installation\InstallerUtils; +use function Directus\get_directus_setting; class User { @@ -74,6 +75,14 @@ public function changePassword($email, $password) { $auth = $this->app->getContainer()->get('auth'); + + $passwordValidation = get_directus_setting('password_policy'); + if(!empty($passwordValidation)){ + if(!preg_match($passwordValidation, $password, $match)){ + throw new PasswordChangeException('Password is not valid.'); + } + } + $hash = $auth->hashPassword($password); $user = $this->usersTableGateway->select(['email' => $email])->current(); diff --git a/src/core/Directus/Database/TableGateway/RelationalTableGateway.php b/src/core/Directus/Database/TableGateway/RelationalTableGateway.php index 95adc3c0e6..3b501f3c56 100644 --- a/src/core/Directus/Database/TableGateway/RelationalTableGateway.php +++ b/src/core/Directus/Database/TableGateway/RelationalTableGateway.php @@ -1296,7 +1296,7 @@ protected function parseCondition($condition) 'logical' => $logical ]; } - + protected function parseDotFilters(Builder $mainQuery, array $filters) { foreach ($filters as $column => $condition) { @@ -1601,7 +1601,156 @@ protected function shouldIgnoreQueryFilter($operator, $value) return in_array($operator, $operators) && empty($value) && !is_numeric($value); } + + /** + * Process single relation field filter + * + * @param Builder $mainQuery + * @param string $column + * @param array | string $condition + * + * @return + */ + protected function processRelationalFilter(Builder $mainQuery, $column, $condition){ + $columnList = $filterColumns = explode('.', $column); + $columnsTable = [ + $this->getTable() + ]; + + $nextColumn = array_shift($columnList); + $nextTable = $this->getTable(); + $relational = SchemaService::hasRelationship($nextTable, $nextColumn); + $relationalTables = []; + while ($relational) { + $relationalTables[$nextColumn] = $nextTable; + $nextTable = SchemaService::getRelatedCollectionName($nextTable, $nextColumn); + $nextColumn = array_shift($columnList); + if (empty($nextColumn)) + break; + // Confirm the user has permission to all chained (dot) fields + if ($this->acl && !$this->acl->canRead($nextTable)) { + throw new Exception\ForbiddenFieldAccessException($nextColumn); + } + + $relational = SchemaService::hasRelationship($nextTable, $nextColumn); + $columnsTable[] = $nextTable; + } + + // if one of the column in the list has not relationship + // it will break the loop before going over all the columns + // which we will call this as column not found + // TODO: Better error message + if (!empty($columnList)) { + throw new Exception\FieldNotFoundException($nextColumn); + } + + //Prepare relational data for all the fields + $columnRelationalData = []; + foreach ($filterColumns as $filterColumn) { + if (isset($relationalTables[$filterColumn])) { + $collection = $this->getTableSchema($relationalTables[$filterColumn]); + $fieldRelation = $collection->getField($filterColumn)->getRelationship(); + $columnRelationalData[$filterColumn] = [ + "type" => $fieldRelation->getType(), + "collection_many" => $fieldRelation->getCollectionMany(), + "field_many" => $fieldRelation->getFieldMany(), + "collection_one" => $fieldRelation->getCollectionOne(), + "field_one" => $fieldRelation->getFieldOne() + ]; + } + } + + // Reverse all the columns from comments.author.id to id.author.comments + // To filter from the most deep relationship to their parents + $columns = explode('.', \Directus\column_identifier_reverse($column)); + $columnsTable = array_reverse($columnsTable, true); + + $mainColumn = array_pop($columns); + $mainTable = array_pop($columnsTable); + + // the main query column + // where the filter is going to be applied + $column = array_shift($columns); + $table = array_shift($columnsTable); + + $query = new Builder($this->getAdapter()); + $mainTableObject = $this->getTableSchema($table); + $selectColumn = $mainTableObject->getPrimaryField()->getName(); + + //check if column type is alias and relationship is O2M + $previousRelation = isset($filterColumns[array_search($column, $filterColumns) - 1]) ? $filterColumns[array_search($column, $filterColumns) - 1] : ''; + if ($previousRelation && $columnRelationalData[$previousRelation]['type'] == \Directus\Database\Schema\Object\FieldRelationship::ONE_TO_MANY) { + $selectColumn = $columnRelationalData[$previousRelation]['field_many']; + } + + //get last relationship + if ($mainColumn && !empty($mainColumn) && $columnRelationalData[$mainColumn]['type'] == \Directus\Database\Schema\Object\FieldRelationship::ONE_TO_MANY) { + $mainColumn = $mainTableObject->getPrimaryField()->getName(); + } + $query->columns([$selectColumn]); + + $query->from($table); + + $this->doFilter($query, $column, $condition, $table); + $index = 0; + foreach ($columns as $key => $column) { + ++$index; + + $oldQuery = $query; + $query = new Builder($this->getAdapter()); + $collection = $this->getTableSchema($columnsTable[$key]); + $field = $collection->getField($column); + + $selectColumn = $collection->getPrimaryField()->getName(); + //check if column type is alias and relationship is O2M + $previousRelation = isset($filterColumns[array_search($column, $filterColumns) - 1]) ? $filterColumns[array_search($column, $filterColumns) - 1] : ''; + if ($previousRelation && $columnRelationalData[$previousRelation]['type'] == \Directus\Database\Schema\Object\FieldRelationship::ONE_TO_MANY) { + $selectColumn = $columnRelationalData[$previousRelation]['field_many']; + } + $table = $columnsTable[$key]; + if ($field->isAlias()) { + $column = $collection->getPrimaryField()->getName(); + } + + $query->columns([$selectColumn]); + $query->from($table); + $query->whereIn($column, $oldQuery); + } + + $collection = $this->getTableSchema($mainTable); + $field = $collection->getField($mainColumn); + $relationship = $field->getRelationship(); + + // TODO: Make all this whereIn duplication into a function + // TODO: Can we make the O2M simpler getting the parent id from itself + // right now is creating one unnecessary select + /*if ($field->isOneToMany()) { + $mainColumn = $collection->getPrimaryField()->getName(); + $oldQuery = $query; + $query = new Builder($this->getAdapter()); + $selectColumn = $column = $relationship->getFieldOne(); + $table = $relationship->getCollectionOne(); + + $query->columns([$selectColumn]); + $query->from($table); + $query->whereIn( + $column, + $oldQuery + ); + }*/ + + $this->doFilter( + $mainQuery, + $mainColumn, + [ + 'in' => $query, + 'logical' => isset($condition['logical']) ? $condition['logical'] : 'and' + ], + $mainTable + ); + } + /** * Process Select Filters (Where conditions) * @@ -1610,32 +1759,36 @@ protected function shouldIgnoreQueryFilter($operator, $value) */ protected function processFilter(Builder $query, array $filters = []) { - //Logic for blacklisted fields $blackListStatuses = []; foreach ($filters as $column => $conditions) { - $column = explode('.', $column); - $column = array_shift($column); - $fieldReadBlackListDetails = $this->acl->getStatusesOnReadFieldBlacklist($this->getTable(), $column); + //Logic for blacklisted fields + $field = explode('.', $column); + $field = array_shift($field); + $fieldReadBlackListDetails = $this->acl->getStatusesOnReadFieldBlacklist($this->getTable(), $field); if (isset($fieldReadBlackListDetails['isReadBlackList']) && $fieldReadBlackListDetails['isReadBlackList']) { - throw new Exception\ForbiddenFieldAccessException($column); + throw new Exception\ForbiddenFieldAccessException($field); } else if (isset($fieldReadBlackListDetails['statuses']) && !empty($fieldReadBlackListDetails['statuses'])) { $blackListStatuses = array_merge($blackListStatuses, array_values($fieldReadBlackListDetails['statuses'])); } - } - $filters = $this->parseDotFilters($query, $filters); - - foreach ($filters as $column => $conditions) { - if ($conditions instanceof Filter) { - $column = $conditions->getIdentifier(); - $conditions = $conditions->getValue(); - } + + if (!(!is_string($column) || strpos($column, '.') === false)){ + //Process relational & non relation field filters sequentially + //Earlier, all the relation field filters were processing first and then non relation fields, due to that logical operators were not working in mix filters + //Reference #1149 + $this->processRelationalFilter($query, $column, $conditions); + }else{ + if ($conditions instanceof Filter) { + $column = $conditions->getIdentifier(); + $conditions = $conditions->getValue(); + } - if (!is_array($conditions) || !isset($conditions[0])) { - $conditions = [$conditions]; - } + if (!is_array($conditions) || !isset($conditions[0])) { + $conditions = [$conditions]; + } - foreach ($conditions as $condition) { - $this->doFilter($query, $column, $condition, $this->getTable()); + foreach ($conditions as $condition) { + $this->doFilter($query, $column, $condition, $this->getTable()); + } } } //Condition for blacklisted statuses diff --git a/src/core/Directus/Filesystem/Files.php b/src/core/Directus/Filesystem/Files.php index 8cf032c4d7..5778d066da 100644 --- a/src/core/Directus/Filesystem/Files.php +++ b/src/core/Directus/Filesystem/Files.php @@ -258,12 +258,14 @@ public function saveData($fileData, $fileName, $replace = false) { // When file is uploaded via multipart form data then We will get object of Slim\Http\UploadFile // When file is uploaded via URL (Youtube, Vimeo, or image link) then we will get base64 encode string. + $size = null; if (is_object($fileData)) { - + $size = $fileData->getSize(); $checksum = hash_file('md5', $fileData->file); } else { $fileData = base64_decode($this->getDataInfo($fileData)['data']); $checksum = md5($fileData); + $size = strlen($fileData); } // @TODO: merge with upload() $fileName = $this->getFileName($fileName, $replace !== true); @@ -272,9 +274,9 @@ public function saveData($fileData, $fileName, $replace = false) - $this->emitter->run('file.save', ['name' => $fileName, 'size' => strlen($fileData)]); + $this->emitter->run('file.save', ['name' => $fileName, 'size' => $size]); $this->write($fileName, $fileData, $replace); - $this->emitter->run('file.save:after', ['name' => $fileName, 'size' => strlen($fileData)]); + $this->emitter->run('file.save:after', ['name' => $fileName, 'size' => $size]); unset($fileData); @@ -683,19 +685,17 @@ public function getFileSizeType($data) $result=[]; if (is_a_url($data)) { $dataInfo = $this->getLink($data); - $result['mimeType']=$dataInfo['type']; - $result['size']=$dataInfo['filesize'] ? $dataInfo['filesize'] : $dataInfo['size']; - } - if(is_object($data)) { + $result['mimeType'] = isset($dataInfo['type']) ? $dataInfo['type'] : null; + $result['size'] = isset($dataInfo['filesize']) ? $dataInfo['filesize'] : (isset($dataInfo['size']) ? $dataInfo['size'] : null); + }else if(is_object($data)) { $result['mimeType']=$data->getClientMediaType(); $result['size']=$data->getSize(); - } - if(strpos($data, 'data:') === 0){ + }else if(strpos($data, 'data:') === 0){ $parts = explode(',', $data); $file = $parts[1]; $dataInfo = $this->getFileInfoFromData(base64_decode($file)); - $result['mimeType']=$dataInfo['type']; - $result['size']=$dataInfo['size']; + $result['mimeType'] = isset($dataInfo['type']) ? $dataInfo['type'] : null; + $result['size'] = isset($dataInfo['size']) ? $dataInfo['size'] : null; } return $result; } diff --git a/src/core/Directus/Filesystem/Thumbnail.php b/src/core/Directus/Filesystem/Thumbnail.php index 0b25751939..cc86dbe210 100644 --- a/src/core/Directus/Filesystem/Thumbnail.php +++ b/src/core/Directus/Filesystem/Thumbnail.php @@ -116,12 +116,13 @@ public static function createImageFromNonImage($content, $format = 'jpeg') if (!extension_loaded('imagick')) { return false; } - $image = new \Imagick(); $image->readImageBlob($content); $image->setIteratorIndex(0); $image->setImageFormat($format); - + $image->setImageBackgroundColor('#ffffff'); + $image->setImageAlphaChannel(\Imagick::ALPHACHANNEL_REMOVE); + $image = $image->mergeImageLayers(\Imagick::LAYERMETHOD_FLATTEN); return $image->getImageBlob(); } diff --git a/src/core/Directus/Filesystem/Thumbnailer.php b/src/core/Directus/Filesystem/Thumbnailer.php index 2d8ebb39c6..94ee68d7d0 100644 --- a/src/core/Directus/Filesystem/Thumbnailer.php +++ b/src/core/Directus/Filesystem/Thumbnailer.php @@ -8,6 +8,7 @@ use Directus\Util\StringUtils; use Intervention\Image\ImageManagerStatic as Image; use Exception; +use Imagick; class Thumbnailer { @@ -144,6 +145,20 @@ public function getThumbnailMimeType() } } + /** + * Replace PDF files with a JPG thumbnail + * @throws Exception + * @return string image content + */ + public function load () { + $content = $this->filesystem->read($this->fileName); + $ext = pathinfo($this->fileName, PATHINFO_EXTENSION); + if (Thumbnail::isNonImageFormatSupported($ext)) { + $content = Thumbnail::createImageFromNonImage($content); + } + return Image::make($content); + } + /** * Create thumbnail from image and `contain` * http://image.intervention.io/api/resize @@ -159,7 +174,7 @@ public function contain() $options = $this->getSupportedActionOptions($this->action); // open file image resource - $img = Image::make($this->filesystem->read($this->fileName)); + $img = $this->load(); // crop image $img->resize($this->width, $this->height, function ($constraint) { @@ -192,11 +207,12 @@ public function contain() public function crop() { try { + // action options $options = $this->getSupportedActionOptions($this->action); // open file image resource - $img = Image::make($this->filesystem->read($this->fileName)); + $img = $this->load(); // resize/crop image $img->fit($this->width, $this->height, function($constraint){}, ArrayUtils::get($options, 'position', 'center')); diff --git a/src/core/Directus/Logger/Exception/InvalidLoggerConfigurationException.php b/src/core/Directus/Logger/Exception/InvalidLoggerConfigurationException.php index efa6fcb7d2..0249cad1d4 100644 --- a/src/core/Directus/Logger/Exception/InvalidLoggerConfigurationException.php +++ b/src/core/Directus/Logger/Exception/InvalidLoggerConfigurationException.php @@ -1,6 +1,6 @@ enforceCreatePermissions($collection, $payload, $params); $this->validatePayload($collection, null, $payload, $params); + // Validate Password if password policy settled in the system settings. + if($collection == SchemaManager::COLLECTION_USERS){ + $passwordValidation = get_directus_setting('password_policy'); + if(!empty($passwordValidation)){ + $this->validate($payload,[static::PASSWORD_FIELD => ['regex:'.$passwordValidation ]]); + } + } + //Validate nested payload $tableSchema = SchemaService::getCollection($collection); $collectionAliasColumns = $tableSchema->getAliasFields(); foreach ($collectionAliasColumns as $aliasColumnDetails) { $colName = $aliasColumnDetails->getName(); + $relationalCollectionName = ""; if($this->isManyToManyField($aliasColumnDetails)){ @@ -182,7 +196,7 @@ public function validateParentCollectionFields($collection, $payload, $params, $ foreach($tableColumns as $key => $column){ if(!empty($recordData) && !$column->hasPrimaryKey()){ $columnName = $column->getName(); - $collectionFields[$columnName] = isset($collectionFields[$column->getName()]) ? $collectionFields[$column->getName()]: $recordData[$columnName]; + $collectionFields[$columnName] = array_key_exists($column->getName(), $collectionFields) ? $collectionFields[$column->getName()]: (DataTypes::isJson($column->getType()) ? (array) $recordData[$columnName] : $recordData[$columnName]); } } @@ -211,14 +225,14 @@ public function validateManyToManyCollection($payload, $params, $aliasColumnDeta $columnName = $column->getName(); if($search !== false){ $dbObj = isset($storedData[$search][$aliasField]) ? $storedData[$search][$aliasField] : []; - $validatePayload[$columnName] = isset($validatePayload[$columnName]) ? $validatePayload[$columnName]: (isset($dbObj[$columnName]) ? $dbObj[$columnName] : null); + $validatePayload[$columnName] = array_key_exists($columnName, $validatePayload) ? $validatePayload[$columnName]: (isset($dbObj[$columnName]) ? ((DataTypes::isJson($column->getType()) ? (array) $dbObj[$columnName] : $dbObj[$columnName])) : null); }else{ $relationalCollectionData = $this->findByIds( $relationalCollectionName, $validatePayload[$relationalCollectionPrimaryKey], $params ); - $validatePayload[$columnName] = isset($validatePayload[$columnName]) ? $validatePayload[$columnName]: (isset($relationalCollectionData['data'][$columnName]) ? $relationalCollectionData['data'][$columnName] : null); + $validatePayload[$columnName] = array_key_exists($columnName, $validatePayload) ? $validatePayload[$columnName]: (isset($relationalCollectionData['data'][$columnName]) ? ((DataTypes::isJson($column->getType()) ? (array) $relationalCollectionData['data'][$columnName] : $relationalCollectionData['data'][$columnName])) : null); } } } @@ -255,14 +269,14 @@ public function validateAliasCollection($payload, $params, $aliasColumnDetails, $search = array_search($individual[$relationalCollectionPrimaryKey], array_column($recordData[$colName], $relationalCollectionPrimaryKey)); $columnName = $column->getName(); if($search !== false){ - $individual[$columnName] = isset($individual[$columnName]) ? $individual[$columnName]: (isset($recordData[$colName][$search][$columnName]) ? $recordData[$colName][$search][$columnName] : null); + $individual[$columnName] = array_key_exists($columnName, $individual) ? $individual[$columnName]: (isset($recordData[$colName][$search][$columnName]) ? ((DataTypes::isJson($column->getType()) ? (array) $recordData[$colName][$search][$columnName] : $recordData[$colName][$search][$columnName])) : null); }else{ $relationalCollectionData = $this->findByIds( $relationalCollectionName, $individual[$relationalCollectionPrimaryKey], $params ); - $individual[$columnName] = isset($individual[$columnName]) ? $individual[$columnName]: (isset($relationalCollectionData['data'][$columnName]) ? $relationalCollectionData['data'][$columnName] : null); + $individual[$columnName] = array_key_exists($columnName, $individual) ? $individual[$columnName]: (isset($relationalCollectionData['data'][$columnName]) ? ((DataTypes::isJson($column->getType()) ? (array) $relationalCollectionData['data'][$columnName] : $relationalCollectionData['data'][$columnName])) : null); } } } @@ -277,6 +291,40 @@ public function validateAliasCollection($payload, $params, $aliasColumnDetails, } } + /** + * Check relational items are deletable or not. If it is not deletable then throws the exception. + */ + + public function checkRelationalItemDeletable($collection, $payload, $recordData){ + $tableColumns = SchemaService::getAllCollectionFields($collection); + $deletedData = 0; + foreach($tableColumns as $column){ + + // Check if field is O2M and required + if($column->hasRelationship() && $column->isOneToMany() && ($column->isRequired() || (!$column->isNullable() && $column->getDefaultValue() == null))){ + if(!empty($recordData)){ + $columnName = $column->getName(); + + if(isset($recordData[$columnName]) && isset($payload[$columnName]) && count($recordData[$columnName]) == count($payload[$columnName]) ){ + $fieldMany = $column->getRelationship()->getFieldMany(); + $collectionMany = SchemaService::getCollection($column->getRelationship()->getCollectionMany()); + $primaryKeyCollectionMany = $collectionMany->getPrimaryKeyName(); + $alreadyStoredEntries = array_column($recordData[$columnName],$primaryKeyCollectionMany); + foreach($payload[$columnName] as $payloadData){ + if(isset($payloadData[$primaryKeyCollectionMany]) && in_array($payloadData[$primaryKeyCollectionMany],$alreadyStoredEntries)){ + if(isset($payloadData['$delete']) || ( array_key_exists($fieldMany, $payloadData) && is_null($payloadData[$fieldMany]))){ + $deletedData++; + } + } + } + if($deletedData == count($recordData[$columnName])){ + throw new UnprocessableEntityException($columnName.': This value should not be blank.'); + } + } + } + } + } + } /** * Updates a single item in the given collection and id @@ -308,6 +356,9 @@ public function update($collection, $id, $payload, array $params = []) $this->validateAliasCollection($payload, $params, $aliasColumnDetails, $recordData); } } + + // There is a scenario in which user tries to delete all the relational data although it is required. This can be possible for o2M only and API have to restrict that. + $this->checkRelationalItemDeletable($collection, $payload, $recordData); $this->checkItemExists($collection, $id); diff --git a/src/core/Directus/Services/UsersService.php b/src/core/Directus/Services/UsersService.php index a314367b91..0ab0c399d5 100644 --- a/src/core/Directus/Services/UsersService.php +++ b/src/core/Directus/Services/UsersService.php @@ -20,9 +20,13 @@ use Directus\Util\JWTUtils; use Zend\Db\Sql\Delete; use Zend\Db\Sql\Select; +use function Directus\get_directus_setting; class UsersService extends AbstractService { + + const PASSWORD_FIELD = 'password'; + /** * @var string */ @@ -56,6 +60,12 @@ public function update($id, array $payload, array $params = []) $this->enforceUpdatePermissions($this->collection, $payload, $params); $this->validatePayload($this->collection, array_keys($payload), $payload, $params); + + $passwordValidation = get_directus_setting('password_policy'); + if(!empty($passwordValidation)){ + $this->validate($payload,[static::PASSWORD_FIELD => ['regex:'.$passwordValidation ]]); + } + $this->checkItemExists($this->collection, $id); $tableGateway = $this->createTableGateway($this->collection); diff --git a/src/core/Directus/Util/StringUtils.php b/src/core/Directus/Util/StringUtils.php index 42942b56ca..bf80fe2c88 100644 --- a/src/core/Directus/Util/StringUtils.php +++ b/src/core/Directus/Util/StringUtils.php @@ -126,7 +126,7 @@ public static function random($length = 16) public static function randomString($length = 16) { // TODO: Add options to allow symbols or user provided characters to extend the list - $pool = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + $pool = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*()_+}{';'?>.<,"; return substr(str_shuffle(str_repeat($pool, $length)), 0, $length); } diff --git a/src/endpoints/Settings.php b/src/endpoints/Settings.php index 4bfdb9e5b7..6ce625c789 100644 --- a/src/endpoints/Settings.php +++ b/src/endpoints/Settings.php @@ -90,8 +90,11 @@ public function all(Request $request, Response $response) switch ($fieldDefinition['type']) { case 'file': if (!empty($responseData['data'][$index]['value'])) { - $fileInstance = $service->findFile($responseData['data'][$index]['value']); - $responseData['data'][$index]['value'] = null; + try{ + $fileInstance = $service->findFile($responseData['data'][$index]['value']); + }catch(\Exception $e){ + $responseData['data'][$index]['value'] = null; + } if (!empty($fileInstance['data'])) { $responseData['data'][$index]['value'] = $fileInstance['data']; diff --git a/src/helpers/file.php b/src/helpers/file.php index 6c4ea937eb..d36069b559 100644 --- a/src/helpers/file.php +++ b/src/helpers/file.php @@ -6,6 +6,7 @@ use Directus\Filesystem\Thumbnail; use function Directus\get_directus_setting; use Directus\Validator\Exception\InvalidRequestException; +use Directus\Util\MimeTypeUtils; if (!function_exists('is_uploaded_file_okay')) { /** @@ -198,7 +199,8 @@ function get_thumbnails(array $row) explode(',', get_directus_setting('thumbnail_dimensions')) ); - if (!$type || (strpos($type, 'image/') !== 0 && strpos($type, 'embed/') !== 0)) { + $fileExtension = MimeTypeUtils::getFromMimeType($type); + if (!in_array($fileExtension, Thumbnail::getFormatsSupported()) && strpos($type, 'embed/') !== 0) { return null; }