forkbb/app/Core/Validator.php

976 lines
27 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/**
* This file is part of the ForkBB <https://github.com/forkbb>.
*
* @copyright (c) Visman <mio.visman@yandex.ru, https://github.com/MioVisman>
* @license The MIT License (MIT)
*/
declare(strict_types=1);
namespace ForkBB\Core;
use ForkBB\Core\Container;
use ForkBB\Core\File;
use ForkBB\Core\RulesValidator;
use InvalidArgumentException;
use RuntimeException;
use function \ForkBB\__;
class Validator
{
/**
* Массив валидаторов
*/
protected array $validators;
/**
* Массив правил для текущей проверки данных
*/
protected array $rules;
/**
* Массив результатов проверенных данных
*/
protected array $result;
/**
* Массив дополнительных аргументов для валидаторов и конкретных полей/правил
*/
protected array $arguments;
/**
* Массив сообщений об ошибках для конкретных полей/правил
*/
protected array $messages;
/**
* Массив псевдонимов имен полей для вывода в ошибках
*/
protected array $aliases;
/**
* Массив ошибок валидации
*/
protected array $errors;
/**
* Массив имен полей для обработки
*/
protected array $fields;
/**
* Массив состояний проверки полей
*/
protected array $status;
/**
* Массив входящих данных для обработки
*/
protected ?array $raw;
/**
* Данные для текущей обработки
*/
protected array $curData;
/**
* Флаг ошибки
*/
protected ?bool $error = null;
public function __construct(protected Container $c)
{
$this->reset();
}
/**
* Сбрасывает настройки к начальным состояниям
*/
public function reset(): Validator
{
$this->validators = [
'absent' => [$this, 'vAbsent'],
'array' => [$this, 'vArray'],
'checkbox' => [$this, 'vCheckbox'],
'date' => [$this, 'vDate'],
'exist' => [$this, 'vExist'],
'file' => [$this, 'vFile'],
'image' => [$this, 'vImage'],
'in' => [$this, 'vIn'],
'integer' => [$this, 'vInteger'],
'max' => [$this, 'vMax'],
'min' => [$this, 'vMin'],
'numeric' => [$this, 'vNumeric'],
'not_in' => [$this, 'vNotIn'],
'password' => [$this, 'vPassword'],
'referer' => [$this, 'vReferer'],
'regex' => [$this, 'vRegex'],
'required' => [$this, 'vRequired'],
'required_with' => [$this, 'vRequiredWith'],
'same' => [$this, 'vSame'],
'string' => [$this, 'vString'],
'token' => [$this, 'vToken'],
];
$this->rules = [];
$this->result = [];
$this->arguments = [];
$this->messages = [];
$this->aliases = [];
$this->errors = [];
$this->fields = [];
$this->status = [];
return $this;
}
/**
* Добавляет валидаторы
*/
public function addValidators(array $validators): Validator
{
$this->validators = \array_replace($this->validators, $validators);
return $this;
}
/**
* Добавляет правила
*/
public function addRules(array $list): Validator
{
foreach ($list as $field => $raw) {
$rules = [];
$suffix = null;
// правило для элементов массива
if (\strpos($field, '.') > 0) {
list($field, $suffix) = \explode('.', $field, 2);
}
if (! \is_array($raw)) {
$raw = \explode('|', $raw);
}
// перебор правил для текущего поля
foreach ($raw as $name => $rule) {
if (! \is_string($name)) {
list($name, $rule) = \array_pad(\explode(':', $rule, 2), 2, '');
}
if (empty($this->validators[$name])) {
try {
$validator = $this->c->{"VL{$name}"};
} catch (Exception $e) {
$validator = null;
}
if ($validator instanceof RulesValidator) {
$this->validators[$name] = [$validator, $name];
} else {
throw new RuntimeException("{$name} validator not found");
}
}
if (
'array' === $name
&& ! \is_array($rule)
) {
$rule = [];
}
$rules[$name] = $rule ?? '';
}
if (isset($suffix)) {
$this->rules[$field]['array'][$suffix] = $rules;
} else {
$this->rules[$field] = $rules;
}
$this->fields[$field] = $field;
}
return $this;
}
/**
* Добавляет дополнительные аргументы для конкретных "имя поля"."имя правила".
*/
public function addArguments(array $arguments): Validator
{
$this->arguments = \array_replace($this->arguments, $arguments);
return $this;
}
/**
* Добавляет сообщения для конкретных "имя поля"."имя правила".
*/
public function addMessages(array $messages): Validator
{
$this->messages = \array_replace($this->messages, $messages);
return $this;
}
/**
* Добавляет псевдонимы имен полей для сообщений об ошибках
*/
public function addAliases(array $aliases): Validator
{
$this->aliases = \array_replace($this->aliases, $aliases);
return $this;
}
/**
* Проверяет данные
*/
public function validation(array $raw, bool $strict = false): bool
{
if (empty($this->rules)) {
throw new RuntimeException('Rules not found');
}
$this->errors = [];
$this->status = [];
$this->curData = [];
$this->raw = $raw;
foreach ($this->fields as $field) {
$this->__get($field);
}
if (
$strict
&& empty($this->errors)
&& ! empty(\array_diff_key($this->raw, $this->fields))
) {
$this->addError('Too much data');
}
$this->raw = null;
return empty($this->errors);
}
/**
* Проверяет наличие поля
*/
public function __isset(string $field): bool
{
return isset($this->result[$field]);
}
/**
* Проверяет поле согласно заданным правилам
* Возвращает значение запрашиваемого поля
*/
public function __get(string $field): mixed
{
if (isset($this->status[$field])) {
return $this->result[$field];
} elseif (empty($this->rules[$field])) {
throw new RuntimeException("No rules for '{$field}' field");
}
if (isset($this->raw[$field])) {
$value = $this->c->Secury->replInvalidChars($this->raw[$field]);
} else {
$value = null;
}
if (
null === $value
&& isset($this->rules[$field]['required'])
) {
$rules = ['required' => ''];
} else {
$rules = $this->rules[$field];
}
$value = $this->checkValue($value, $rules, $field);
$this->status[$field] = true !== $this->error; // в $this->error может быть состояние false
$this->result[$field] = $value;
return $value;
}
/**
* Проверяет значение списком правил
*/
protected function checkValue(mixed $value, array $rules, string $field): mixed
{
foreach ($rules as $validator => $attr) {
// данные для обработчика ошибок
$this->error = null;
$this->curData[] = [
'field' => $field,
'rule' => $validator,
'attr' => $attr,
];
$value = $this->validators[$validator]($this, $value, $attr, $this->getArguments($field, $validator));
\array_pop($this->curData);
if (null !== $this->error) {
break;
}
}
return $value;
}
/**
* Добавляет ошибку
*/
public function addError(string|array|null $error, string $type = FORK_MESS_VLD): void
{
if (empty($vars = \end($this->curData))) {
throw new RuntimeException('The array of variables is empty');
}
// нет ошибки, для выхода из цикла проверки правил
if (null === $error) {
$this->error = false;
return;
}
\extract($vars);
$alias = $this->aliases[$field] ?? $field;
$message = $this->messages["{$field}.{$rule}"]
?? $this->messages[$field]
?? (\is_string($error) ? $error : null);
if (isset($message)) {
if (\is_array($message)) {
list($type, $message) = $message;
}
$this->errors[$type][] = \is_array($message)
? $message
: [$message, [':alias' => __($alias), ':attr' => $attr]];
} elseif (\is_array($error)) {
$this->errors[$type][] = $error;
} else {
throw new InvalidArgumentException('Expected string or array');
}
$this->error = true;
}
/**
* Возвращает дополнительные аргументы
*/
protected function getArguments(string $field, string $rule): mixed
{
return $this->arguments["{$field}.{$rule}"] ?? $this->arguments[$field] ?? null;
}
/**
* Возвращает статус проверки поля
*/
public function getStatus(string $field): bool
{
if (! isset($this->status[$field])) {
$this->__get($field);
}
return $this->status[$field];
}
/**
* Возвращает проверенные данные
* Поля с ошибками содержат значения по умолчанию или значения с ошибками
*/
public function getData(bool $all = false, array $doNotUse = null): array
{
if (empty($this->status)) {
throw new RuntimeException('Data not found');
}
if (null === $doNotUse) {
$result = $this->result;
} else {
$result = \array_diff_key($this->result, \array_flip($doNotUse));
}
if ($all) {
return $result;
} else {
return \array_filter($result, function ($value) {
return null !== $value;
});
}
}
/**
* Возращает массив ошибок
*/
public function getErrors(): array
{
return $this->errors;
}
/**
* Возращает массив ошибок без разделения на типы
*/
public function getErrorsWithoutType(): array
{
$result = [];
foreach ($this->errors as $errors) {
\array_push($result, ...$errors);
}
return $result;
}
/**
* Удаляет пробельные символы UTF-8 по краям строки
*/
public function trim(string $value): string
{
return \preg_replace('%^\s+|\s+$%u', '', $value);
}
/**
* Проверяет переменную на отсутсвие содержимого
*/
public function noValue(mixed $value, bool $withArray = false): bool
{
if (null === $value) {
return true;
} elseif (\is_string($value)) {
return '' === $this->trim($value);
} elseif (\is_array($value)) {
return $withArray && empty($value);
} else {
return false;
}
}
/**
* Выполняет проверку значения по правилу
*
* @param Validator $first ссылка на валидатор
* @param mixed $second проверяемое значение
* @param mixed $third атрибут правила
* @param mixed $fourth дополнительный аргумент
*
* @return mixed
*/
protected function vAbsent(Validator $v, mixed $value, string $attr): mixed
{
if (null === $value) {
if (isset($attr[0])) {
$value = $attr;
}
} else {
$this->addError('The :alias should be absent');
}
return $value;
}
protected function vExist(Validator $v, mixed $value): mixed
{
if (null === $value) {
$this->addError('The :alias not exist');
}
return $value;
}
protected function vRequired(Validator $v, mixed $value): mixed
{
if ($this->noValue($value, true)) {
$this->addError('The :alias is required');
$value = null;
}
return $value;
}
protected function vRequiredWith(Validator $v, mixed $value, string $attr): mixed
{
foreach (\explode(',', $attr) as $field) {
if (null !== $this->__get($field)) { // если есть хотя бы одно поле,
return $this->vRequired($v, $value); // то проверяем данное поле
} // на обязательное наличие
}
if (null === $value) { // если данное поле отсутствует,
$this->addError(null); // то прерываем его проверку
}
return $value;
}
protected function vString(Validator $v, mixed $value, string $attr): ?string
{
if (\is_string($value)) {
if (isset($attr[0])) {
foreach (\explode(',', $attr) as $action) {
switch ($action) {
case 'trim':
$value = $this->trim($value);
break;
case 'lower':
$value = \mb_strtolower($value, 'UTF-8');
break;
case 'spaces':
$value = \preg_replace('%\s+%u', ' ', $value);
break;
case 'linebreaks':
$value = \str_replace(["\r\n", "\r"], "\n", $value);
break;
case 'null':
if ('' === $value) {
$value = null;
$this->addError(null);
break 2;
}
break;
case 'empty':
if ('' === $value) {
$this->addError(null);
break 2;
}
break;
default:
throw new InvalidArgumentException("Bad action: {$action}");
}
}
}
} elseif (null === $value) {
$this->addError(null);
} else {
$this->addError('The :alias must be string');
$value = \is_scalar($value) ? (string) $value : null;
}
return $value;
}
protected function vNumeric(Validator $v, mixed $value): mixed
{
if (\is_numeric($value)) {
$value += 0;
} elseif (
null === $value
|| '' === $value
) {
$this->addError(null);
$value = null;
} else {
$this->addError('The :alias must be numeric');
$value = \is_scalar($value) ? (string) $value : null;
}
return $value;
}
protected function vInteger(Validator $v, mixed $value): mixed
{
if (
\is_numeric($value)
&& \is_int(0 + $value)
) {
$value += 0;
} elseif (
null === $value
|| '' === $value
) {
$this->addError(null);
$value = null;
} else {
$this->addError('The :alias must be integer');
$value = \is_scalar($value) ? (string) $value : null;
}
return $value;
}
protected function vArray(Validator $v, mixed $value, array $attr): ?array
{
if (
null !== $value
&& ! \is_array($value)
) {
$this->addError('The :alias must be array');
return null;
} elseif (! $attr) {
return $value;
}
if (empty($vars = \end($this->curData))) {
throw new RuntimeException('The array of variables is empty');
}
$result = [];
foreach ($attr as $name => $rules) {
$this->recArray($value, $result, $name, $rules, "{$vars['field']}.{$name}");
}
return $result;
}
protected function recArray(&$value, &$result, $name, $rules, $field)
{
if ('' === $name) {
$result = $this->checkValue($value, $rules, $field);
} else {
if (! \preg_match('%^([^.]+)(?:\.(.+))?$%', $name, $matches)) {
throw new RuntimeException("Bad path '{$name}'");
}
$key = $matches[1];
$name = $matches[2] ?? '';
if (
'*' === $key
&& \is_array($value)
) {
foreach ($value as $i => $cur) {
$this->recArray($value[$i], $result[$i], $name, $rules, $field);
}
} elseif (
'*' !== $key
&& \is_array($value)
&& \array_key_exists($key, $value)
) {
$this->recArray($value[$key], $result[$key], $name, $rules, $field);
} elseif (isset($rules['required'])) {
$tmp1 = null;
$tmp2 = null;
$this->recArray($tmp1, $tmp2, $name, $rules, $field);
} elseif ('*' === $key) {
$result = []; // ???? а может там не отсутствие элемента, а не array?
} else {
$value[$key] = null;
$this->recArray($value[$key], $result[$key], $name, $rules, $field);
}
}
}
protected function vMin(Validator $v, mixed $value, string $attr): mixed
{
if (! \preg_match('%^(-?\d+)(\s*bytes)?$%i', $attr, $matches)) {
throw new InvalidArgumentException('Expected number in attribute');
}
$min = (int) $matches[1];
$inBytes = ! empty($matches[2]);
if (\is_string($value)) {
if (
(
$inBytes
&& \strlen($value) < $min
)
|| (
! $inBytes
&& \mb_strlen($value, 'UTF-8') < $min
)
) {
$this->addError('The :alias minimum is :attr characters');
}
} elseif (\is_numeric($value)) {
if (0 + $value < $min) {
$this->addError('The :alias minimum is :attr');
}
} elseif (\is_array($value)) {
if (\count($value) < $min) {
$this->addError('The :alias minimum is :attr elements');
}
} else {
$this->addError('The :alias minimum is :attr');
$value = null;
}
return $value;
}
protected function vMax(Validator $v, mixed $value, string $attr): mixed
{
if (! \preg_match('%^(-?\d+)(\s*bytes)?$%i', $attr, $matches)) {
throw new InvalidArgumentException('Expected number in attribute');
}
$max = (int) $matches[1];
$inBytes = ! empty($matches[2]);
if (\is_string($value)) {
if (
(
$inBytes
&& \strlen($value) > $max
)
|| (
! $inBytes
&& \mb_strlen($value, 'UTF-8') > $max
)
) {
$this->addError('The :alias maximum is :attr characters');
}
} elseif (\is_numeric($value)) {
if (0 + $value > $max) {
$this->addError('The :alias maximum is :attr');
}
} elseif (\is_array($value)) {
if (\reset($value) instanceof File) {
foreach ($value as $file) {
if ($file->size() > $max * 1024) {
$this->addError('The :alias contains too large a file');
$value = null;
break;
}
}
} elseif (\count($value) > $max) {
$this->addError('The :alias maximum is :attr elements');
}
} elseif ($value instanceof File) {
if ($value->size() > $max * 1024) {
$this->addError('The :alias contains too large a file');
$value = null;
}
} else {
$this->addError('The :alias maximum is :attr');
$value = null;
}
return $value;
}
protected function vToken(Validator $v, mixed $value, string $attr, mixed $args): ?string
{
if (! \is_array($args)) {
$args = [];
}
if (\preg_match('%^([1-9]\d+):(.+)$%', $attr, $matches)) {
$lifetime = (int) $matches[1];
$attr = $matches[2];
} else {
$lifetime = null;
}
if (
! \is_string($value)
|| ! $this->c->Csrf->verify($value, $attr, $args, $lifetime)
) {
$this->addError($this->c->Csrf->getError() ?? 'Bad token', FORK_MESS_ERR);
$value = null;
}
return $value;
}
protected function vCheckbox(Validator $v, mixed $value): mixed
{
if (null === $value) {
$this->addError(null);
$value = false;
} elseif (\is_scalar($value)) {
$value = (string) $value;
} else {
$this->addError('The :alias contains an invalid value');
$value = null;
}
return $value;
}
protected function vReferer(Validator $v, mixed $value, string $attr, mixed $args): string
{
if (! \is_array($args)) {
$args = [];
}
return $this->c->Router->validate($value, $attr, $args);
}
protected function vSame(Validator $v, mixed $value, string $attr): mixed
{
if (
$this->getStatus($attr)
&& $value !== $this->__get($attr)
) {
$this->addError('The :alias must be same with original');
$value = null;
}
return $value;
}
protected function vRegex(Validator $v, string $value, string $attr): string
{
if (! \preg_match($attr, $value)) {
$this->addError('The :alias is not valid format');
}
return $value;
}
protected function vPassword(Validator $v, string $value): string
{
return $this->vRegex($v, $value, '%[^\x20][\x20][^\x20]%');
}
protected function vIn(Validator $v, mixed $value, string|array $attr): mixed
{
if (! \is_scalar($value)) {
$value = null;
}
if (
null === $value
|| (
\is_array($attr)
&& ! \in_array($value, $attr, true)
)
|| (
! \is_array($attr)
&& ! \in_array($value, \explode(',', $attr))
)
) {
$this->addError('The :alias contains an invalid value');
}
return $value;
}
protected function vNotIn(Validator $v, mixed $value, string|array $attr): mixed
{
if (! \is_scalar($value)) {
$value = null;
}
if (
null === $value
|| (
\is_array($attr)
&& \in_array($value, $attr, true)
)
|| (
! \is_array($attr)
&& \in_array($value, \explode(',', $attr))
)
) {
$this->addError('The :alias contains an invalid value');
}
return $value;
}
protected function vFile(Validator $v, mixed $value, string $attr): mixed
{
if ($this->noValue($value, true)) {
$this->addError(null);
return null;
}
if (! \is_array($value)) {
$this->addError('The :alias not contains file');
return null;
}
$value = $this->c->Files->upload($value);
if (null === $value) {
$this->addError(null);
return null;
} elseif (false === $value) {
$this->addError($this->c->Files->error());
return null;
} elseif ('multiple' === $attr) {
if (! \is_array($value)) {
$value = [$value];
}
} elseif (\is_array($value)) {
$this->addError('The :alias contains more than one file');
return null;
}
return $value;
}
protected function vImage(Validator $v, mixed $value, string $attr): mixed
{
$value = $this->vFile($v, $value, $attr);
if (\is_array($value)) {
foreach ($value as $file) {
if (null === $this->c->Files->imageExt($file)) {
$this->addError('The :alias not contains image');
return null;
}
}
} elseif (
null !== $value
&& null === $this->c->Files->imageExt($value)
) {
$this->addError('The :alias not contains image');
return null;
}
return $value;
}
protected function vDate(Validator $v, mixed $value): ?string
{
if ($this->noValue($value)) {
return null;
}
if (\is_string($value)) {
$timestamp = $this->c->Func->dateToTime($value);
} else {
$timestamp = false;
}
if (false === $timestamp) {
$v->addError('The :alias does not contain a date');
} elseif ($timestamp < 0) {
$v->addError('The :alias contains time before start of Unix');
}
return \is_scalar($value) ? (string) $value : null;
}
}