<?php
namespace XF;
use XF\Mvc\Controller;
use XF\Util\Json;
use function array_filter;
use function array_map;
use function array_values;
use function count;
use function enum_exists;
use function explode;
use function function_exists;
use function in_array;
use function is_array;
use function is_numeric;
use function is_object;
use function is_scalar;
use function is_string;
use function mb_strlen;
use function preg_match;
use function str_contains;
use function str_starts_with;
use function strlen;
use function strtolower;
use function trim;
final readonly class RequestValidator
{
public function __construct(private Controller $controller)
{
}
/**
* @param array<string, array<int, mixed>|string> $rules
*
* @return array<string, mixed>
* @throws \XF\Mvc\Reply\Exception
*/
public function validate(array $rules): array
{
$out = [];
foreach ($rules as $field => $ruleSet) {
$parsed = $this->parseRuleSet($ruleSet);
$nullable = $parsed['nullable'];
$required = $parsed['required'];
$type = $parsed['type'] ?? 'str';
$rulesList = $parsed['rules'];
$value = $this->filterValue($field, $type);
if ($this->isEmpty($value)) {
if ($required) {
$this->fail($field, "Field '$field' is required.", 400);
}
if ($nullable) {
$out[$field] = null;
continue;
}
$out[$field] = $value;
continue;
}
$this->applyRules($field, $value, $type, $rulesList);
$out[$field] = $value;
}
return $out;
}
/**
* @param array<int, mixed>|string $ruleSet
*
* @return array{nullable: bool, required: bool, type: null|string, rules: array<int, mixed>}
*/
private function parseRuleSet(string|array $ruleSet): array
{
$parts = is_array($ruleSet) ? $ruleSet : explode('|', $ruleSet);
$nullable = false;
$required = false;
$type = null;
$rules = [];
foreach ($parts as $p) {
if (is_string($p)) {
$p = trim($p);
if ($p === '') {
continue;
}
$pl = strtolower($p);
if ($pl === 'nullable') {
$nullable = true;
continue;
}
if ($pl === 'required') {
$required = true;
continue;
}
if ($type === null && $this->looksLikeType($p)) {
$type = $p;
continue;
}
$rules[] = $p;
continue;
}
$rules[] = $p;
}
return [
'nullable' => $nullable,
'required' => $required,
'type' => $type,
'rules' => $rules,
];
}
private function looksLikeType(string $p): bool
{
$pl = strtolower($p);
return $pl === 'str'
|| $pl === 'string'
|| $pl === 'int'
|| $pl === 'integer'
|| $pl === 'uint'
|| $pl === 'posint'
|| $pl === 'num'
|| $pl === 'unum'
|| $pl === 'float'
|| $pl === 'bool'
|| $pl === 'boolean'
|| $pl === 'array'
|| $pl === 'json-array'
|| $pl === 'datetime'
|| str_starts_with($pl, 'array-');
}
private function filterValue(string $field, string $type): mixed
{
$t = strtolower($type);
if ($t === 'json-array') {
$raw = $this->controller->filter($field, 'str');
if ($raw === null || $raw === '') {
return $raw;
}
if (!is_string($raw)) {
return $raw;
}
try {
$decoded = Json::decode($raw, true);
} catch (\Throwable) {
$this->fail($field, "Field '$field' must be a valid JSON array.", 400);
}
if (!is_array($decoded)) {
$this->fail($field, "Field '$field' must be a JSON array.", 400);
}
return $decoded;
}
if (str_starts_with($t, 'array-')) {
$sub = trim(substr($t, 6));
$arr = $this->controller->filter($field, 'array');
if ($this->isEmpty($arr)) {
return $arr;
}
if (!is_array($arr)) {
$this->fail($field, "Field '$field' must be an array.", 400);
}
$out = [];
foreach ($arr as $k => $v) {
$out[$k] = $this->filterScalarLike($field, $v, $sub);
}
return $out;
}
return $this->controller->filter($field, $type);
}
private function filterScalarLike(string $field, mixed $value, string $type): mixed
{
$t = strtolower($type);
if ($value === null || $value === '') {
return $value;
}
if ($t === 'str' || $t === 'string') {
return (string) $value;
}
if ($t === 'bool' || $t === 'boolean') {
return (bool) $value;
}
if ($t === 'float') {
if (!is_numeric($value)) {
$this->fail($field, "Field '$field' has invalid value.", 400);
}
return (float) $value;
}
if ($t === 'int' || $t === 'integer' || $t === 'uint' || $t === 'posint' || $t === 'num' || $t === 'unum') {
if (!is_numeric($value)) {
$this->fail($field, "Field '$field' has invalid value.", 400);
}
return (int) $value;
}
return $value;
}
/**
* @param array<int, mixed> $rules
*/
private function applyRules(string $field, mixed &$value, string $type, array $rules): void
{
foreach ($rules as $r) {
if (is_object($r)) {
if ($r instanceof EnumRule) {
$this->applyEnumRule($field, $value, $r);
continue;
}
\XF::logError([
'validator' => self::class,
'error' => 'Unknown rule object',
'field' => $field,
'ruleClass' => $r::class,
]);
$this->fail($field, "Unknown rule object for '$field'.", 500);
}
$name = (string) $r;
$arg = null;
if (str_contains($name, ':')) {
[$name, $arg] = explode(':', $name, 2);
$name = trim($name);
$arg = trim($arg);
}
$nl = strtolower($name);
if ($nl === 'in') {
$allowed = $arg === null
? []
: array_values(array_filter(
array_map(trim(...), explode(',', $arg)),
static fn ($v) => $v !== ''
));
if (is_array($value)) {
foreach ($value as $v) {
if (!in_array((string) $v, $allowed, true)) {
$this->fail($field, "Field '$field' has invalid value.", 400);
}
}
} elseif (!in_array((string) $value, $allowed, true)) {
$this->fail($field, "Field '$field' has invalid value.", 400);
}
continue;
}
if ($nl === 'min' || $nl === 'max') {
if ($arg === null || !is_numeric($arg)) {
\XF::logError([
'validator' => self::class,
'error' => 'Invalid rule argument',
'field' => $field,
'rule' => $nl,
'arg' => $arg,
]);
$this->fail($field, "Rule '$nl' for '$field' is invalid.", 500);
}
$limit = (float) $arg;
if ($this->isNumericType($type)) {
$actual = (float) $value;
if ($nl === 'min') {
if ($actual < $limit) {
$this->fail($field, "Field '$field' is too small.", 400);
}
} elseif ($actual > $limit) {
$this->fail($field, "Field '$field' is too large.", 400);
}
} else {
$actual = $this->sizeOf($value);
if ($nl === 'min') {
if ($actual < (int) $limit) {
$this->fail($field, "Field '$field' is too short.", 400);
}
} elseif ($actual > (int) $limit) {
$this->fail($field, "Field '$field' is too long.", 400);
}
}
continue;
}
if ($nl === 'regex') {
if ($arg === null || $arg === '') {
\XF::logError([
'validator' => self::class,
'error' => 'Invalid regex rule argument',
'field' => $field,
]);
$this->fail($field, "Rule 'regex' for '$field' is invalid.", 500);
}
if (!preg_match($arg, (string) $value)) {
$this->fail($field, "Field '$field' has invalid format.", 400);
}
continue;
}
if ($nl === 'distinct') {
if (is_array($value)) {
$seen = [];
foreach ($value as $v) {
$k = $this->getDistinctKey($v);
if (isset($seen[$k])) {
$this->fail($field, "Field '$field' must not contain duplicates.", 400);
}
$seen[$k] = true;
}
}
continue;
}
\XF::logError([
'validator' => self::class,
'error' => 'Unknown rule',
'field' => $field,
'rule' => $name,
]);
$this->fail($field, "Unknown rule '$name' for '$field'.", 500);
}
}
private function applyEnumRule(string $field, mixed &$value, EnumRule $rule): void
{
$enumClass = $rule->enumClass;
if (!enum_exists($enumClass)) {
\XF::logError([
'validator' => self::class,
'error' => 'Invalid enum class',
'field' => $field,
'enumClass' => $enumClass,
]);
$this->fail($field, "Enum for '$field' is invalid.", 500);
}
if ($value instanceof $enumClass) {
return;
}
if (!is_scalar($value) || $enumClass::tryFrom((string) $value) === null) {
$this->fail($field, "Field '$field' has invalid enum value.", 400);
}
}
private function isEmpty(mixed $value): bool
{
if ($value === null) {
return true;
}
if ($value === '') {
return true;
}
return (bool) (is_array($value) && count($value) === 0);
}
private function isNumericType(string $type): bool
{
$t = strtolower($type);
return $t === 'int'
|| $t === 'integer'
|| $t === 'uint'
|| $t === 'posint'
|| $t === 'num'
|| $t === 'unum'
|| $t === 'float';
}
private function sizeOf(mixed $value): int
{
if (is_array($value)) {
return count($value);
}
return $this->stringLen((string) $value);
}
private function stringLen(string $s): int
{
if (function_exists('mb_strlen')) {
return (int) mb_strlen($s, 'UTF-8');
}
return strlen($s);
}
private function fail(string $field, string $message, int $responseCode): never
{
throw $this->controller->exception(
$this->controller->error($message, $responseCode)
);
}
private function getDistinctKey(mixed $value): string
{
if ($value === null) {
return 'null';
}
if (is_string($value)) {
return 's:' . $value;
}
if (is_scalar($value)) {
return gettype($value) . ':' . $value;
}
if (is_array($value)) {
return 'a:' . json_encode(
$value,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PARTIAL_OUTPUT_ON_ERROR
);
}
if (is_object($value)) {
if ($value instanceof \Stringable) {
return 'o:' . $value::class . ':' . $value;
}
return 'o:' . $value::class . '#' . spl_object_id($value);
}
return gettype($value);
}
}