* @link https://getkirby.com * @copyright Bastian Allgeier * @license https://opensource.org/licenses/MIT */ class V { /** * An array with all installed validators */ public static array $validators = []; /** * Validates the given input with all passed rules * and returns an array with all error messages. * The array will be empty if the input is valid */ public static function errors( $input, array $rules, array $messages = [] ): array { $errors = static::value($input, $rules, $messages, false); return $errors === true ? [] : $errors; } /** * Runs a number of validators on a set of data and * checks if the data is invalid * @since 3.7.0 */ public static function invalid( array $data = [], array $rules = [], array $messages = [] ): array { $errors = []; foreach ($rules as $field => $validations) { $validationIndex = -1; // See: http://php.net/manual/en/types.comparisons.php // only false for: null, undefined variable, '', [] $value = $data[$field] ?? null; $filled = $value !== null && $value !== '' && $value !== []; $message = $messages[$field] ?? $field; // True if there is an error message for each validation method. $messageArray = is_array($message); foreach ($validations as $method => $options) { // If the index is numeric, there is no option // and `$value` is sent directly as a `$options` parameter if (is_numeric($method) === true) { $method = $options; $options = [$value]; } else { if (is_array($options) === false) { $options = [$options]; } array_unshift($options, $value); } $validationIndex++; if ($method === 'required') { if ($filled) { // Field is required and filled. continue; } } elseif ($filled) { if (V::$method(...$options) === true) { // Field is filled and passes validation method. continue; } } else { // If a field is not required and not filled, no validation should be done. continue; } // If no continue was called we have a failed validation. if ($messageArray) { $errors[$field][] = $message[$validationIndex] ?? $field; } else { $errors[$field] = $message; } } } return $errors; } /** * Creates a useful error message for the given validator * and the arguments. This is used mainly internally * to create error messages */ public static function message( string $validatorName, ...$params ): string|null { $validatorName = strtolower($validatorName); $translationKey = 'error.validation.' . $validatorName; $validators = array_change_key_case(static::$validators); $validator = $validators[$validatorName] ?? null; if ($validator === null) { return null; } $reflection = new ReflectionFunction($validator); $arguments = []; foreach ($reflection->getParameters() as $index => $parameter) { $value = $params[$index] ?? null; if (is_array($value) === true) { foreach ($value as $key => $item) { if (is_array($item) === true) { $value[$key] = A::implode($item, '|'); } } $value = implode(', ', $value); } $arguments[$parameter->getName()] = $value; } return I18n::template($translationKey, 'The "' . $validatorName . '" validation failed', $arguments); } /** * Return the list of all validators */ public static function validators(): array { return static::$validators; } /** * Validate a single value against * a set of rules, using all registered * validators */ public static function value( $value, array $rules, array $messages = [], bool $fail = true ): bool|array { $errors = []; foreach ($rules as $validatorName => $validatorOptions) { if (is_int($validatorName)) { $validatorName = $validatorOptions; $validatorOptions = []; } if (is_array($validatorOptions) === false) { $validatorOptions = [$validatorOptions]; } $validatorName = strtolower($validatorName); if (static::$validatorName($value, ...$validatorOptions) === false) { $message = $messages[$validatorName] ?? static::message($validatorName, $value, ...$validatorOptions); $errors[$validatorName] = $message; if ($fail === true) { throw new Exception($message); } } } return empty($errors) === true ? true : $errors; } /** * Validate an input array against * a set of rules, using all registered * validators */ public static function input(array $input, array $rules): bool { foreach ($rules as $fieldName => $fieldRules) { $fieldValue = $input[$fieldName] ?? null; // first check for required fields if ( ($fieldRules['required'] ?? false) === true && $fieldValue === null ) { throw new Exception(sprintf('The "%s" field is missing', $fieldName)); } // remove the required rule unset($fieldRules['required']); // skip validation for empty fields if ($fieldValue === null) { continue; } try { static::value($fieldValue, $fieldRules); } catch (Exception $e) { throw new Exception(sprintf($e->getMessage() . ' for field "%s"', $fieldName)); } } return true; } /** * Calls an installed validator and passes all arguments */ public static function __callStatic(string $method, array $arguments): bool { $method = strtolower($method); $validators = array_change_key_case(static::$validators); // check for missing validators if (isset($validators[$method]) === false) { throw new Exception('The validator does not exist: ' . $method); } return call_user_func_array($validators[$method], $arguments); } } /** * Default set of validators */ V::$validators = [ /** * Valid: `'yes' | true | 1 | 'on'` */ 'accepted' => function ($value): bool { return V::in($value, [1, true, 'yes', 'true', '1', 'on'], true) === true; }, /** * Valid: `a-z | A-Z` */ 'alpha' => function ($value, bool $unicode = false): bool { return V::match($value, ($unicode === true ? '/^([\pL])+$/u' : '/^([a-z])+$/i')) === true; }, /** * Valid: `a-z | A-Z | 0-9` */ 'alphanum' => function ($value, bool $unicode = false): bool { return V::match($value, ($unicode === true ? '/^[\pL\pN]+$/u' : '/^([a-z0-9])+$/i')) === true; }, /** * Checks for numbers within the given range */ 'between' => function ($value, $min, $max): bool { return V::min($value, $min) === true && V::max($value, $max) === true; }, /** * Checks with the callback sent by the user * It's ideal for one-time custom validations */ 'callback' => function ($value, callable $callback): bool { return $callback($value); }, /** * Checks if the given string contains the given value */ 'contains' => function ($value, $needle): bool { return Str::contains($value, $needle); }, /** * Checks for a valid date or compares two * dates with each other. * * Pass only the first argument to check for a valid date. * Pass an operator as second argument and another date as * third argument to compare them. */ 'date' => function (string|null $value, string $operator = null, string $test = null): bool { // make sure $value is a string $value ??= ''; $args = func_get_args(); // simple date validation if (count($args) === 1) { $date = date_parse($value); return $date !== false && $date['error_count'] === 0 && $date['warning_count'] === 0; } $value = strtotime($value); $test = strtotime($test); if (is_int($value) !== true || is_int($test) !== true) { return false; } return match ($operator) { '!=' => $value !== $test, '<' => $value < $test, '>' => $value > $test, '<=' => $value <= $test, '>=' => $value >= $test, '==' => $value === $test, default => throw new InvalidArgumentException('Invalid date comparison operator: "' . $operator . '". Allowed operators: "==", "!=", "<", "<=", ">", ">="') }; }, /** * Valid: `'no' | false | 0 | 'off'` */ 'denied' => function ($value): bool { return V::in($value, [0, false, 'no', 'false', '0', 'off'], true) === true; }, /** * Checks for a value, which does not equal the given value */ 'different' => function ($value, $other, $strict = false): bool { if ($strict === true) { return $value !== $other; } return $value != $other; }, /** * Checks for valid email addresses */ 'email' => function ($value): bool { if (filter_var($value, FILTER_VALIDATE_EMAIL) === false) { try { $email = Idn::encodeEmail($value); } catch (Throwable) { return false; } return filter_var($email, FILTER_VALIDATE_EMAIL) !== false; } return true; }, /** * Checks for empty values */ 'empty' => function ($value = null): bool { $empty = ['', null, []]; if (in_array($value, $empty, true) === true) { return true; } if (is_countable($value) === true) { return count($value) === 0; } return false; }, /** * Checks if the given string ends with the given value */ 'endsWith' => function (string $value, string $end): bool { return Str::endsWith($value, $end); }, /** * Checks for a valid filename */ 'filename' => function ($value): bool { return V::match($value, '/^[a-z0-9@._-]+$/i') === true && V::min($value, 2) === true; }, /** * Checks if the value exists in a list of given values */ 'in' => function ($value, array $in, bool $strict = false): bool { return in_array($value, $in, $strict) === true; }, /** * Checks for a valid integer */ 'integer' => function ($value, bool $strict = false): bool { if ($strict === true) { return is_int($value) === true; } return filter_var($value, FILTER_VALIDATE_INT) !== false; }, /** * Checks for a valid IP address */ 'ip' => function ($value): bool { return filter_var($value, FILTER_VALIDATE_IP) !== false; }, /** * Checks for valid json */ 'json' => function ($value): bool { if (!is_string($value) || $value === '') { return false; } json_decode($value); return json_last_error() === JSON_ERROR_NONE; }, /** * Checks if the value is lower than the second value */ 'less' => function ($value, float $max): bool { return V::size($value, $max, '<') === true; }, /** * Checks if the value matches the given regular expression */ 'match' => function ($value, string $pattern): bool { return preg_match($pattern, (string)$value) === 1; }, /** * Checks if the value does not exceed the maximum value */ 'max' => function ($value, float $max): bool { return V::size($value, $max, '<=') === true; }, /** * Checks if the value is higher than the minimum value */ 'min' => function ($value, float $min): bool { return V::size($value, $min, '>=') === true; }, /** * Checks if the number of characters in the value equals or is below the given maximum */ 'maxLength' => function (string $value = null, $max): bool { return Str::length(trim($value)) <= $max; }, /** * Checks if the number of characters in the value equals or is greater than the given minimum */ 'minLength' => function (string $value = null, $min): bool { return Str::length(trim($value)) >= $min; }, /** * Checks if the number of words in the value equals or is below the given maximum */ 'maxWords' => function (string $value = null, $max): bool { return V::max(explode(' ', trim($value)), $max) === true; }, /** * Checks if the number of words in the value equals or is below the given maximum */ 'minWords' => function (string $value = null, $min): bool { return V::min(explode(' ', trim($value)), $min) === true; }, /** * Checks if the first value is higher than the second value */ 'more' => function ($value, float $min): bool { return V::size($value, $min, '>') === true; }, /** * Checks that the given string does not contain the second value */ 'notContains' => function ($value, $needle): bool { return V::contains($value, $needle) === false; }, /** * Checks that the given value is not empty */ 'notEmpty' => function ($value): bool { return V::empty($value) === false; }, /** * Checks that the given value is not in the given list of values */ 'notIn' => function ($value, $notIn): bool { return V::in($value, $notIn) === false; }, /** * Checks for a valid number / numeric value (float, int, double) */ 'num' => function ($value): bool { return is_numeric($value) === true; }, /** * Checks if the value is present */ 'required' => function ($value, $array = null): bool { // with reference array if (is_array($array) === true) { return isset($array[$value]) === true && V::notEmpty($array[$value]) === true; } // without reference array return V::notEmpty($value); }, /** * Checks that the first value equals the second value */ 'same' => function ($value, $other, bool $strict = false): bool { if ($strict === true) { return $value === $other; } return $value == $other; }, /** * Checks that the value has the given size */ 'size' => function ($value, $size, $operator = '=='): bool { // if value is field object, first convert it to a readable value // it is important to check at the beginning as the value can be string or numeric if ($value instanceof Field) { $value = $value->value(); } if (is_numeric($value) === true) { $count = $value; } elseif (is_string($value) === true) { $count = Str::length(trim($value)); } elseif (is_array($value) === true) { $count = count($value); } elseif (is_object($value) === true) { if ($value instanceof Countable) { $count = count($value); } elseif (method_exists($value, 'count') === true) { $count = $value->count(); } else { throw new Exception('$value is an uncountable object'); } } else { throw new Exception('$value is of type without size'); } return match ($operator) { '<' => $count < $size, '>' => $count > $size, '<=' => $count <= $size, '>=' => $count >= $size, default => $count == $size }; }, /** * Checks that the string starts with the given start value */ 'startsWith' => function (string $value, string $start): bool { return Str::startsWith($value, $start); }, /** * Checks for a valid unformatted telephone number */ 'tel' => function ($value): bool { return V::match($value, '!^[+]{0,1}[0-9]+$!'); }, /** * Checks for valid time */ 'time' => function ($value): bool { return V::date($value); }, /** * Checks for a valid Url */ 'url' => function ($value): bool { // In search for the perfect regular expression: https://mathiasbynens.be/demo/url-regex // Added localhost support and removed 127.*.*.* ip restriction $regex = '_^(?:(?:https?|ftp):\/\/)(?:\S+(?::\S*)?@)?(?:(?!10(?:\.\d{1,3}){3})(?!169\.254(?:\.\d{1,3}){2})(?!192\.168(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:localhost)|(?:(?:[a-z\x{00a1}-\x{ffff}0-9]+-?)*[a-z\x{00a1}-\x{ffff}0-9]+)(?:\.(?:[a-z\x{00a1}-\x{ffff}0-9]+-?)*[a-z\x{00a1}-\x{ffff}0-9]+)*(?:\.(?:[a-z\x{00a1}-\x{ffff}]{2,})))(?::\d{2,5})?(?:\/[^\s]*)?$_iu'; return preg_match($regex, $value ?? '') !== 0; }, /** * Checks for a valid Uuid, optionally for specific model type */ 'uuid' => function (string $value, string $type = null): bool { return Uuid::is($value, $type); } ];