diff --git a/application/Espo/Classes/FieldSanitizers/Phone.php b/application/Espo/Classes/FieldSanitizers/Phone.php new file mode 100644 index 0000000000..08b5c48f85 --- /dev/null +++ b/application/Espo/Classes/FieldSanitizers/Phone.php @@ -0,0 +1,77 @@ +get($field); + + if ($number !== null) { + $number = $this->phoneNumberSanitizer->sanitize($number); + + $data->set($field, $number); + } + + $items = $data->get($field . 'Data'); + + if (!is_array($items)) { + return; + } + + foreach ($items as $item) { + if (!$item instanceof stdClass) { + continue; + } + + $number = $item->phoneNumber ?? null; + + if (!is_scalar($number)) { + continue; + } + + $number = (string) $number; + + $item->phoneNumber = $this->phoneNumberSanitizer->sanitize($number); + } + + $data->set($field . 'Data', $items); + } +} diff --git a/application/Espo/Core/FieldSanitize/SanitizeManager.php b/application/Espo/Core/FieldSanitize/SanitizeManager.php new file mode 100644 index 0000000000..ca7d8f6221 --- /dev/null +++ b/application/Espo/Core/FieldSanitize/SanitizeManager.php @@ -0,0 +1,114 @@ +fieldUtil->getEntityTypeFieldList($entityType) as $field) { + if (!$this->isFieldSetInData($entityType, $field, $rawData)) { + continue; + } + + $this->processField($entityType, $field, $data); + } + } + + private function processField(string $entityType, string $field, Data $data): void + { + foreach ($this->getSanitizerList($entityType, $field) as $sanitizer) { + $sanitizer->sanitize($data, $entityType, $field); + } + } + + private function isFieldSetInData(string $entityType, string $field, stdClass $data): bool + { + $attributeList = $this->fieldUtil->getActualAttributeList($entityType, $field); + + $isSet = false; + + foreach ($attributeList as $attribute) { + if (property_exists($data, $attribute)) { + $isSet = true; + + break; + } + } + + return $isSet; + } + + /** + * @return Sanitizer[] + */ + private function getSanitizerList(string $entityType, string $field): array + { + $fieldType = $this->fieldUtil->getFieldType($entityType, $field); + + if (!$fieldType) { + return []; + } + + /** @var ?class-string $className */ + $className = $this->metadata->get("fields.$fieldType.sanitizerClassName"); + + if ($className) { + $classNameList[] = $className; + } + + /** @var class-string[] $classNameList */ + $classNameList = $this->metadata->get("entityDefs.$entityType.fields.$field.sanitizerClassNameList") ?? []; + + $classNameList = array_merge( + $className ? [$className] : [], + $classNameList + ); + + return array_map( + fn ($className) => $this->injectableFactory->create($className), + $classNameList + ); + } +} diff --git a/application/Espo/Core/FieldSanitize/Sanitizer.php b/application/Espo/Core/FieldSanitize/Sanitizer.php new file mode 100644 index 0000000000..099af460fa --- /dev/null +++ b/application/Espo/Core/FieldSanitize/Sanitizer.php @@ -0,0 +1,37 @@ +data->$attribute ?? null; + } + + + /** + * Whether a value is set. + */ + public function has(string $attribute): bool + { + return property_exists($this->data, $attribute); + } + + /** + * Update a value. + */ + public function set(string $attribute, mixed $value): self + { + $this->data->$attribute = $value; + + return $this; + } + + /** + * Unset an attribute. + */ + public function clear(string $attribute): self + { + unset($this->data->$attribute); + + return $this; + } +} diff --git a/application/Espo/Core/PhoneNumber/Sanitizer.php b/application/Espo/Core/PhoneNumber/Sanitizer.php index 43dd56045e..a22d51298a 100644 --- a/application/Espo/Core/PhoneNumber/Sanitizer.php +++ b/application/Espo/Core/PhoneNumber/Sanitizer.php @@ -39,7 +39,7 @@ class Sanitizer private Config $config ) {} - public function sanitize(string $value, ?string $countryCode): string + public function sanitize(string $value, ?string $countryCode = null): string { $value = trim($value); diff --git a/application/Espo/Core/Record/Service.php b/application/Espo/Core/Record/Service.php index 204a8c5acd..5399b4c44a 100644 --- a/application/Espo/Core/Record/Service.php +++ b/application/Espo/Core/Record/Service.php @@ -40,6 +40,7 @@ use Espo\Core\Exceptions\Forbidden; use Espo\Core\Exceptions\ForbiddenSilent; use Espo\Core\Exceptions\NotFound; use Espo\Core\Exceptions\NotFoundSilent; +use Espo\Core\FieldSanitize\SanitizeManager; use Espo\Core\ORM\Entity as CoreEntity; use Espo\Core\ORM\Repository\Option\SaveOption; use Espo\Core\Record\Access\LinkCheck; @@ -367,6 +368,9 @@ class Service implements Crud, } /** + * Warning: Do not extend. + * + * @todo Fix signature. * @param TEntity $entity * @param stdClass $data * @return void @@ -431,6 +435,19 @@ class Service implements Crud, return $this->linkCheck; } + /** + * Sanitize input data. + * + * @param stdClass $data Input data. + * @since 8.1.0 + */ + public function sanitizeInput(stdClass $data): void + { + $manager = $this->injectableFactory->create(SanitizeManager::class); + + $manager->process($this->entityType, $data); + } + /** * @param string $attribute * @param mixed $value @@ -705,6 +722,7 @@ class Service implements Crud, $entity = $this->getRepository()->getNew(); $this->filterCreateInput($data); + $this->sanitizeInput($data); $entity->set($data); @@ -758,6 +776,7 @@ class Service implements Crud, } $this->filterUpdateInput($data); + $this->sanitizeInput($data); $entity = $this->getEntityBeforeUpdate ? $this->getEntity($id) : diff --git a/application/Espo/Core/Utils/FieldUtil.php b/application/Espo/Core/Utils/FieldUtil.php index 37a9633468..013519a989 100644 --- a/application/Espo/Core/Utils/FieldUtil.php +++ b/application/Espo/Core/Utils/FieldUtil.php @@ -37,12 +37,17 @@ class FieldUtil public function __construct(private Metadata $metadata) {} + public function getFieldType(string $entityType, string $field): ?string + { + return $this->metadata->get("entityDefs.$entityType.fields.$field.type"); + } + /** * @return string[] */ private function getAttributeListByType(string $entityType, string $name, string $type): array { - $fieldType = $this->metadata->get('entityDefs.' . $entityType . '.fields.' . $name . '.type'); + $fieldType = $this->getFieldType($entityType, $name); if (!$fieldType) { return []; diff --git a/application/Espo/Resources/metadata/fields/phone.json b/application/Espo/Resources/metadata/fields/phone.json index 88c3bc9ef5..92c1c5bd2b 100644 --- a/application/Espo/Resources/metadata/fields/phone.json +++ b/application/Espo/Resources/metadata/fields/phone.json @@ -77,5 +77,6 @@ "personalData": true, "valueFactoryClassName": "Espo\\Core\\Field\\PhoneNumber\\PhoneNumberGroupFactory", "attributeExtractorClassName": "Espo\\Core\\Field\\PhoneNumber\\PhoneNumberGroupAttributeExtractor", + "sanitizerClassName": "Espo\\Classes\\FieldSanitizers\\Phone", "default": null } diff --git a/schema/metadata/entityDefs.json b/schema/metadata/entityDefs.json index 4bae84cfa9..3bbbfdf531 100644 --- a/schema/metadata/entityDefs.json +++ b/schema/metadata/entityDefs.json @@ -536,6 +536,13 @@ }, "description": "Validations to be bypassed for the field." }, + "sanitizerClassNameList": { + "type": "array", + "items": { + "type": "string" + }, + "description": "A list of sanitizers. Should implement Espo\\Core\\FieldSanitize\\Sanitizer." + }, "inlineEditDisabled": { "type": "boolean", "default": "Disable inline edit." diff --git a/schema/metadata/fields.json b/schema/metadata/fields.json index a6cdfb9db2..b4a331035e 100644 --- a/schema/metadata/fields.json +++ b/schema/metadata/fields.json @@ -167,6 +167,10 @@ "converterClassName": { "type": "string", "description": "A metadata converter. Converts field metadata to ORM metadata. Should implement Espo\\Core\\Utils\\Database\\Orm\\FieldConverter." + }, + "sanitizerClassName": { + "type": "string", + "description": "A sanitizer. Should implement Espo\\Core\\FieldSanitize\\Sanitizer." } } }