From 246a10f058dfbc58ceb51e43b32f62249c1fb8da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Lys=C3=BD?= Date: Wed, 18 Sep 2024 18:18:58 +0200 Subject: [PATCH] Copy Validator to email-editor package Because the email-editor package should be independent. We copy Validator to the package, at least for now. [MAILPOET-6216] --- .../src/Engine/EmailApiController.php | 2 +- .../src/Engine/EmailStylesSchema.php | 2 +- .../src/Engine/Templates/TemplatePreview.php | 2 +- .../email-editor/src/Validator/Builder.php | 57 +++++ .../php/email-editor/src/Validator/Schema.php | 97 ++++++++ .../src/Validator/Schema/AnyOfSchema.php | 37 ++++ .../src/Validator/Schema/ArraySchema.php | 28 +++ .../src/Validator/Schema/BooleanSchema.php | 12 + .../src/Validator/Schema/IntegerSchema.php | 36 +++ .../src/Validator/Schema/NullSchema.php | 12 + .../src/Validator/Schema/NumberSchema.php | 36 +++ .../src/Validator/Schema/ObjectSchema.php | 53 +++++ .../src/Validator/Schema/OneOfSchema.php | 37 ++++ .../src/Validator/Schema/StringSchema.php | 53 +++++ .../src/Validator/ValidationException.php | 22 ++ .../email-editor/src/Validator/Validator.php | 209 ++++++++++++++++++ 16 files changed, 692 insertions(+), 3 deletions(-) create mode 100644 packages/php/email-editor/src/Validator/Builder.php create mode 100644 packages/php/email-editor/src/Validator/Schema.php create mode 100644 packages/php/email-editor/src/Validator/Schema/AnyOfSchema.php create mode 100644 packages/php/email-editor/src/Validator/Schema/ArraySchema.php create mode 100644 packages/php/email-editor/src/Validator/Schema/BooleanSchema.php create mode 100644 packages/php/email-editor/src/Validator/Schema/IntegerSchema.php create mode 100644 packages/php/email-editor/src/Validator/Schema/NullSchema.php create mode 100644 packages/php/email-editor/src/Validator/Schema/NumberSchema.php create mode 100644 packages/php/email-editor/src/Validator/Schema/ObjectSchema.php create mode 100644 packages/php/email-editor/src/Validator/Schema/OneOfSchema.php create mode 100644 packages/php/email-editor/src/Validator/Schema/StringSchema.php create mode 100644 packages/php/email-editor/src/Validator/ValidationException.php create mode 100644 packages/php/email-editor/src/Validator/Validator.php diff --git a/packages/php/email-editor/src/Engine/EmailApiController.php b/packages/php/email-editor/src/Engine/EmailApiController.php index 441a760a48..33d326211b 100644 --- a/packages/php/email-editor/src/Engine/EmailApiController.php +++ b/packages/php/email-editor/src/Engine/EmailApiController.php @@ -2,7 +2,7 @@ namespace MailPoet\EmailEditor\Engine; -use MailPoet\Validator\Builder; +use MailPoet\EmailEditor\Validator\Builder; class EmailApiController { /** diff --git a/packages/php/email-editor/src/Engine/EmailStylesSchema.php b/packages/php/email-editor/src/Engine/EmailStylesSchema.php index eef5f222b6..84ecea0d82 100644 --- a/packages/php/email-editor/src/Engine/EmailStylesSchema.php +++ b/packages/php/email-editor/src/Engine/EmailStylesSchema.php @@ -2,7 +2,7 @@ namespace MailPoet\EmailEditor\Engine; -use MailPoet\Validator\Builder; +use MailPoet\EmailEditor\Validator\Builder; class EmailStylesSchema { public function getSchema(): array { diff --git a/packages/php/email-editor/src/Engine/Templates/TemplatePreview.php b/packages/php/email-editor/src/Engine/Templates/TemplatePreview.php index ff5cec961a..9ef29cd554 100644 --- a/packages/php/email-editor/src/Engine/Templates/TemplatePreview.php +++ b/packages/php/email-editor/src/Engine/Templates/TemplatePreview.php @@ -3,7 +3,7 @@ namespace MailPoet\EmailEditor\Engine\Templates; use MailPoet\EmailEditor\Engine\ThemeController; -use MailPoet\Validator\Builder; +use MailPoet\EmailEditor\Validator\Builder; use WP_Theme_JSON; class TemplatePreview { diff --git a/packages/php/email-editor/src/Validator/Builder.php b/packages/php/email-editor/src/Validator/Builder.php new file mode 100644 index 0000000000..859b9aaa46 --- /dev/null +++ b/packages/php/email-editor/src/Validator/Builder.php @@ -0,0 +1,57 @@ +items($items) : $array; + } + + /** @param array|null $properties */ + public static function object(array $properties = null): ObjectSchema { + $object = new ObjectSchema(); + return $properties === null ? $object : $object->properties($properties); + } + + /** @param Schema[] $schemas */ + public static function oneOf(array $schemas): OneOfSchema { + return new OneOfSchema($schemas); + } + + /** @param Schema[] $schemas */ + public static function anyOf(array $schemas): AnyOfSchema { + return new AnyOfSchema($schemas); + } +} diff --git a/packages/php/email-editor/src/Validator/Schema.php b/packages/php/email-editor/src/Validator/Schema.php new file mode 100644 index 0000000000..6890c21d30 --- /dev/null +++ b/packages/php/email-editor/src/Validator/Schema.php @@ -0,0 +1,97 @@ +schema['type'] ?? ['null']; + return $this->updateSchemaProperty('type', is_array($type) ? $type : [$type, 'null']); + } + + /** @return static */ + public function nonNullable() { + $type = $this->schema['type'] ?? null; + return $type === null + ? $this->unsetSchemaProperty('type') + : $this->updateSchemaProperty('type', is_array($type) ? $type[0] : $type); + } + + /** @return static */ + public function required() { + return $this->updateSchemaProperty('required', true); + } + + /** @return static */ + public function optional() { + return $this->unsetSchemaProperty('required'); + } + + /** @return static */ + public function title(string $title) { + return $this->updateSchemaProperty('title', $title); + } + + /** @return static */ + public function description(string $description) { + return $this->updateSchemaProperty('description', $description); + } + + /** @return static */ + public function default($default) { + return $this->updateSchemaProperty('default', $default); + } + + /** @return static */ + public function field(string $name, $value) { + if (in_array($name, $this->getReservedKeywords(), true)) { + throw new \Exception("Field name '$name' is reserved"); + } + return $this->updateSchemaProperty($name, $value); + } + + public function toArray(): array { + return $this->schema; + } + + public function toString(): string { + $json = json_encode($this->schema, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION); + $error = json_last_error(); + if ($error || $json === false) { + throw new \Exception(json_last_error_msg(), (string)$error); + } + return $json; + } + + /** @return static */ + protected function updateSchemaProperty(string $name, $value) { + $clone = clone $this; + $clone->schema[$name] = $value; + return $clone; + } + + /** @return static */ + protected function unsetSchemaProperty(string $name) { + $clone = clone $this; + unset($clone->schema[$name]); + return $clone; + } + + protected function getReservedKeywords(): array { + return rest_get_allowed_schema_keywords(); + } + + protected function validatePattern(string $pattern): void { + $escaped = str_replace('#', '\\#', $pattern); + $regex = "#$escaped#u"; + if (@preg_match($regex, '') === false) { + throw new \Exception("Invalid regular expression '$regex'"); + } + } +} diff --git a/packages/php/email-editor/src/Validator/Schema/AnyOfSchema.php b/packages/php/email-editor/src/Validator/Schema/AnyOfSchema.php new file mode 100644 index 0000000000..9bbf4990e3 --- /dev/null +++ b/packages/php/email-editor/src/Validator/Schema/AnyOfSchema.php @@ -0,0 +1,37 @@ + [], + ]; + + /** @param Schema[] $schemas */ + public function __construct( + array $schemas + ) { + foreach ($schemas as $schema) { + $this->schema['anyOf'][] = $schema->toArray(); + } + } + + public function nullable(): self { + $null = ['type' => 'null']; + $anyOf = $this->schema['anyOf']; + $value = in_array($null, $anyOf, true) ? $anyOf : array_merge($anyOf, [$null]); + return $this->updateSchemaProperty('anyOf', $value); + } + + public function nonNullable(): self { + $null = ['type' => 'null']; + $anyOf = $this->schema['anyOf']; + $value = array_filter($anyOf, function ($item) use ($null) { + return $item !== $null; + }); + return $this->updateSchemaProperty('anyOf', $value); + } +} diff --git a/packages/php/email-editor/src/Validator/Schema/ArraySchema.php b/packages/php/email-editor/src/Validator/Schema/ArraySchema.php new file mode 100644 index 0000000000..ba813b25fe --- /dev/null +++ b/packages/php/email-editor/src/Validator/Schema/ArraySchema.php @@ -0,0 +1,28 @@ + 'array', + ]; + + public function items(Schema $schema): self { + return $this->updateSchemaProperty('items', $schema->toArray()); + } + + public function minItems(int $value): self { + return $this->updateSchemaProperty('minItems', $value); + } + + public function maxItems(int $value): self { + return $this->updateSchemaProperty('maxItems', $value); + } + + public function uniqueItems(): self { + return $this->updateSchemaProperty('uniqueItems', true); + } +} diff --git a/packages/php/email-editor/src/Validator/Schema/BooleanSchema.php b/packages/php/email-editor/src/Validator/Schema/BooleanSchema.php new file mode 100644 index 0000000000..509607526c --- /dev/null +++ b/packages/php/email-editor/src/Validator/Schema/BooleanSchema.php @@ -0,0 +1,12 @@ + 'boolean', + ]; +} diff --git a/packages/php/email-editor/src/Validator/Schema/IntegerSchema.php b/packages/php/email-editor/src/Validator/Schema/IntegerSchema.php new file mode 100644 index 0000000000..e150e1c6c2 --- /dev/null +++ b/packages/php/email-editor/src/Validator/Schema/IntegerSchema.php @@ -0,0 +1,36 @@ + 'integer', + ]; + + public function minimum(int $value): self { + return $this->updateSchemaProperty('minimum', $value) + ->unsetSchemaProperty('exclusiveMinimum'); + } + + public function exclusiveMinimum(int $value): self { + return $this->updateSchemaProperty('minimum', $value) + ->updateSchemaProperty('exclusiveMinimum', true); + } + + public function maximum(int $value): self { + return $this->updateSchemaProperty('maximum', $value) + ->unsetSchemaProperty('exclusiveMaximum'); + } + + public function exclusiveMaximum(int $value): self { + return $this->updateSchemaProperty('maximum', $value) + ->updateSchemaProperty('exclusiveMaximum', true); + } + + public function multipleOf(int $value): self { + return $this->updateSchemaProperty('multipleOf', $value); + } +} diff --git a/packages/php/email-editor/src/Validator/Schema/NullSchema.php b/packages/php/email-editor/src/Validator/Schema/NullSchema.php new file mode 100644 index 0000000000..4e23e0471b --- /dev/null +++ b/packages/php/email-editor/src/Validator/Schema/NullSchema.php @@ -0,0 +1,12 @@ + 'null', + ]; +} diff --git a/packages/php/email-editor/src/Validator/Schema/NumberSchema.php b/packages/php/email-editor/src/Validator/Schema/NumberSchema.php new file mode 100644 index 0000000000..3249e95078 --- /dev/null +++ b/packages/php/email-editor/src/Validator/Schema/NumberSchema.php @@ -0,0 +1,36 @@ + 'number', + ]; + + public function minimum(float $value): self { + return $this->updateSchemaProperty('minimum', $value) + ->unsetSchemaProperty('exclusiveMinimum'); + } + + public function exclusiveMinimum(float $value): self { + return $this->updateSchemaProperty('minimum', $value) + ->updateSchemaProperty('exclusiveMinimum', true); + } + + public function maximum(float $value): self { + return $this->updateSchemaProperty('maximum', $value) + ->unsetSchemaProperty('exclusiveMaximum'); + } + + public function exclusiveMaximum(float $value): self { + return $this->updateSchemaProperty('maximum', $value) + ->updateSchemaProperty('exclusiveMaximum', true); + } + + public function multipleOf(float $value): self { + return $this->updateSchemaProperty('multipleOf', $value); + } +} diff --git a/packages/php/email-editor/src/Validator/Schema/ObjectSchema.php b/packages/php/email-editor/src/Validator/Schema/ObjectSchema.php new file mode 100644 index 0000000000..ebebebe45b --- /dev/null +++ b/packages/php/email-editor/src/Validator/Schema/ObjectSchema.php @@ -0,0 +1,53 @@ + 'object', + ]; + + /** @param array $properties */ + public function properties(array $properties): self { + return $this->updateSchemaProperty('properties', array_map( + function (Schema $property) { + return $property->toArray(); + }, + $properties + )); + } + + public function additionalProperties(Schema $schema): self { + return $this->updateSchemaProperty('additionalProperties', $schema->toArray()); + } + + public function disableAdditionalProperties(): self { + return $this->updateSchemaProperty('additionalProperties', false); + } + + /** + * Keys of $properties are regular expressions without leading/trailing delimiters. + * See: https://developer.wordpress.org/rest-api/extending-the-rest-api/schema/#patternproperties + * + * @param array $properties + */ + public function patternProperties(array $properties): self { + $patternProperties = []; + foreach ($properties as $key => $value) { + $this->validatePattern($key); + $patternProperties[$key] = $value->toArray(); + } + return $this->updateSchemaProperty('patternProperties', $patternProperties); + } + + public function minProperties(int $value): self { + return $this->updateSchemaProperty('minProperties', $value); + } + + public function maxProperties(int $value): self { + return $this->updateSchemaProperty('maxProperties', $value); + } +} diff --git a/packages/php/email-editor/src/Validator/Schema/OneOfSchema.php b/packages/php/email-editor/src/Validator/Schema/OneOfSchema.php new file mode 100644 index 0000000000..36d4737d2d --- /dev/null +++ b/packages/php/email-editor/src/Validator/Schema/OneOfSchema.php @@ -0,0 +1,37 @@ + [], + ]; + + /** @param Schema[] $schemas */ + public function __construct( + array $schemas + ) { + foreach ($schemas as $schema) { + $this->schema['oneOf'][] = $schema->toArray(); + } + } + + public function nullable(): self { + $null = ['type' => 'null']; + $oneOf = $this->schema['oneOf']; + $value = in_array($null, $oneOf, true) ? $oneOf : array_merge($oneOf, [$null]); + return $this->updateSchemaProperty('oneOf', $value); + } + + public function nonNullable(): self { + $null = ['type' => 'null']; + $oneOf = $this->schema['oneOf']; + $value = array_filter($oneOf, function ($item) use ($null) { + return $item !== $null; + }); + return $this->updateSchemaProperty('oneOf', $value); + } +} diff --git a/packages/php/email-editor/src/Validator/Schema/StringSchema.php b/packages/php/email-editor/src/Validator/Schema/StringSchema.php new file mode 100644 index 0000000000..d002ba5736 --- /dev/null +++ b/packages/php/email-editor/src/Validator/Schema/StringSchema.php @@ -0,0 +1,53 @@ + 'string', + ]; + + public function minLength(int $value): self { + return $this->updateSchemaProperty('minLength', $value); + } + + public function maxLength(int $value): self { + return $this->updateSchemaProperty('maxLength', $value); + } + + /** + * Parameter $pattern is a regular expression without leading/trailing delimiters. + * See: https://developer.wordpress.org/rest-api/extending-the-rest-api/schema/#pattern + */ + public function pattern(string $pattern): self { + $this->validatePattern($pattern); + return $this->updateSchemaProperty('pattern', $pattern); + } + + public function formatDateTime(): self { + return $this->updateSchemaProperty('format', 'date-time'); + } + + public function formatEmail(): self { + return $this->updateSchemaProperty('format', 'email'); + } + + public function formatHexColor(): self { + return $this->updateSchemaProperty('format', 'hex-color'); + } + + public function formatIp(): self { + return $this->updateSchemaProperty('format', 'ip'); + } + + public function formatUri(): self { + return $this->updateSchemaProperty('format', 'uri'); + } + + public function formatUuid(): self { + return $this->updateSchemaProperty('format', 'uuid'); + } +} diff --git a/packages/php/email-editor/src/Validator/ValidationException.php b/packages/php/email-editor/src/Validator/ValidationException.php new file mode 100644 index 0000000000..b0b2237a56 --- /dev/null +++ b/packages/php/email-editor/src/Validator/ValidationException.php @@ -0,0 +1,22 @@ +withMessage($wpError->get_error_message()); + $exception->wpError = $wpError; + return $exception; + } + + public function getWpError(): WP_Error { + return $this->wpError; + } +} diff --git a/packages/php/email-editor/src/Validator/Validator.php b/packages/php/email-editor/src/Validator/Validator.php new file mode 100644 index 0000000000..b61498b0bf --- /dev/null +++ b/packages/php/email-editor/src/Validator/Validator.php @@ -0,0 +1,209 @@ +wp = $wp; + } + + /** + * Strict validation & sanitization implementation. + * It only coerces int to float (e.g. 5 to 5.0). + * + * @param mixed $value + * @return mixed + */ + public function validate(Schema $schema, $value, string $paramName = 'value') { + return $this->validateSchemaArray($schema->toArray(), $value, $paramName); + } + + /** + * Strict validation & sanitization implementation. + * It only coerces int to float (e.g. 5 to 5.0). + * + * @param array $schema. The array must follow the format, which is returned from Schema::toArray(). + * @param mixed $value + * @return mixed + */ + public function validateSchemaArray(array $schema, $value, string $paramName = 'value') { + $result = $this->validateAndSanitizeValueFromSchema($value, $schema, $paramName); + if ($result instanceof WP_Error) { + throw ValidationException::createFromWpError($result); + } + return $result; + } + + /** + * Mirrors rest_validate_value_from_schema() and rest_sanitize_value_from_schema(). + * + * @param mixed $value + * @param array $schema + * @param string $paramName + * @return mixed|WP_Error + */ + private function validateAndSanitizeValueFromSchema($value, array $schema, string $paramName) { + // nullable + $fullType = $schema['type'] ?? null; + if (is_array($fullType) && in_array('null', $fullType, true) && $value === null) { + return null; + } + + // anyOf, oneOf + if (isset($schema['anyOf'])) { + return $this->validateAndSanitizeAnyOf($value, $schema, $paramName); + } elseif (isset($schema['oneOf'])) { + return $this->validateAndSanitizeOneOf($value, $schema, $paramName); + } + + // make types strict + $type = is_array($fullType) ? $fullType[0] : $fullType; + switch ($type) { + case 'number': + if (!is_float($value) && !is_int($value)) { + return $this->getTypeError($paramName, $fullType); + } + break; + case 'integer': + if (!is_int($value)) { + return $this->getTypeError($paramName, $fullType); + } + break; + case 'boolean': + if (!is_bool($value)) { + return $this->getTypeError($paramName, $fullType); + } + break; + case 'array': + if (!is_array($value)) { + return $this->getTypeError($paramName, $fullType); + } + + if (isset($schema['items'])) { + foreach ($value as $i => $v) { + $result = $this->validateAndSanitizeValueFromSchema($v, $schema['items'], $paramName . '[' . $i . ']'); + if ($this->wp->isWpError($result)) { + return $result; + } + } + } + break; + case 'object': + if (!is_array($value) && !$value instanceof stdClass && !$value instanceof JsonSerializable) { + return $this->getTypeError($paramName, $fullType); + } + + // ensure string keys + $value = (array)($value instanceof JsonSerializable ? $value->jsonSerialize() : $value); + if (count(array_filter(array_keys($value), 'is_string')) !== count($value)) { + return $this->getTypeError($paramName, $fullType); + } + + // validate object properties + foreach ($value as $k => $v) { + if (isset($schema['properties'][$k])) { + $result = $this->validateAndSanitizeValueFromSchema($v, $schema['properties'][$k], $paramName . '[' . $k . ']'); + if ($this->wp->isWpError($result)) { + return $result; + } + continue; + } + + $patternPropertySchema = $this->wp->restFindMatchingPatternPropertySchema($k, $schema); + if ($patternPropertySchema) { + $result = $this->validateAndSanitizeValueFromSchema($v, $patternPropertySchema, $paramName . '[' . $k . ']'); + if ($this->wp->isWpError($result)) { + return $result; + } + continue; + } + + if (isset($schema['additionalProperties']) && is_array($schema['additionalProperties'])) { + $result = $this->validateAndSanitizeValueFromSchema($v, $schema['additionalProperties'], $paramName . '[' . $k . ']'); + if ($this->wp->isWpError($result)) { + return $result; + } + } + } + break; + } + + $result = $this->wp->restValidateValueFromSchema($value, $schema, $paramName); + if ($this->wp->isWpError($result)) { + return $result; + } + return $this->wp->restSanitizeValueFromSchema($value, $schema, $paramName); + } + + /** + * Mirrors rest_find_any_matching_schema(). + * + * @param mixed $value + * @return mixed|WP_Error + */ + private function validateAndSanitizeAnyOf($value, array $anyOfSchema, string $paramName) { + $errors = []; + foreach ($anyOfSchema['anyOf'] as $index => $schema) { + $result = $this->validateAndSanitizeValueFromSchema($value, $schema, $paramName); + if (!$this->wp->isWpError($result)) { + return $result; + } + $errors[] = ['error_object' => $result, 'schema' => $schema, 'index' => $index]; + } + return $this->wp->restGetCombiningOperationError($value, $paramName, $errors); + } + + /** + * Mirrors rest_find_one_matching_schema(). + * + * @param mixed $value + * @return mixed|WP_Error + */ + private function validateAndSanitizeOneOf($value, array $oneOfSchema, string $paramName) { + $matchingSchemas = []; + $errors = []; + $data = null; + foreach ($oneOfSchema['oneOf'] as $index => $schema) { + $result = $this->validateAndSanitizeValueFromSchema($value, $schema, $paramName); + if ($this->wp->isWpError($result)) { + $errors[] = ['error_object' => $result, 'schema' => $schema, 'index' => $index]; + } else { + $data = $result; + $matchingSchemas[$index] = $schema; + } + } + + if (!$matchingSchemas) { + return $this->wp->restGetCombiningOperationError($value, $paramName, $errors); + } + + if (count($matchingSchemas) > 1) { + // reuse WP method to generate detailed error + $invalidSchema = ['type' => []]; + $oneOf = array_replace(array_fill(0, count($oneOfSchema['oneOf']), $invalidSchema), $matchingSchemas); + return $this->wp->restFindOneMatchingSchema($value, ['oneOf' => $oneOf], $paramName); + } + return $data; + } + + /** @param string|string[] $type */ + private function getTypeError(string $param, $type): WP_Error { + $type = is_array($type) ? $type : [$type]; + return new WP_Error( + 'rest_invalid_type', + // translators: %1$s is the current parameter and %2$s a comma-separated list of the allowed types. + sprintf(__('%1$s is not of type %2$s.', 'mailpoet'), $param, implode(',', $type)), + ['param' => $param] + ); + } +}