Files
lichterei-web/kirby/src/Toolkit/V.php
Bastian Allgeier 7f4eb7509d Upgrade to 4.2.0
2024-04-10 11:09:52 +02:00

635 lines
16 KiB
PHP

<?php
namespace Kirby\Toolkit;
use Countable;
use Exception;
use Kirby\Content\Field;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Http\Idn;
use Kirby\Uuid\Uuid;
use ReflectionFunction;
use Throwable;
/**
* A set of validator methods
*
* @package Kirby Toolkit
* @author Bastian Allgeier <bastian@getkirby.com>
* @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);
}
];