1256 lines
42 KiB
PHP
1256 lines
42 KiB
PHP
<?php
|
|
|
|
namespace Libs;
|
|
|
|
use Exception;
|
|
|
|
/**
|
|
* Validator - DuckBrain
|
|
*
|
|
* Complementary library to the Request library. Simplifies value verification.
|
|
* It can validate both individual rules and batches (see validateList()).
|
|
*
|
|
* Model: a batch stops at the FIRST failing rule and records it in
|
|
* $lastFailed as "field.rule[:args]"; there is no error bag. Use message() to
|
|
* turn a $lastFailed into a human message with the :attribute placeholder
|
|
* (Request::validate() already does this for its single HTTP 422 error).
|
|
*
|
|
* Rules are a pipe-separated list per field, e.g. "required|email", with
|
|
* optional arguments after a colon. The file rules (file/image/mimes) mark a
|
|
* field as a "file field"; in that case min/max/between/size measure kilobytes
|
|
* per upload and required/nullable act on uploads. Files are checked against
|
|
* their real, sniffed MIME type - never the file name or the client type.
|
|
*
|
|
* |---------------+---------------+---------------------------------------------|
|
|
* | Rule | Arguments | Description |
|
|
* |---------------+---------------+---------------------------------------------|
|
|
* | not | :rule | Negates the following rule. Ex: not:float |
|
|
* | exists | | Must be defined (may be empty) |
|
|
* | required | | Must be defined and not empty |
|
|
* | nullable | | Allows empty; skips the field's other rules |
|
|
* | string | | Must be a string |
|
|
* | array | | Must be an array |
|
|
* | number | | Must be numeric |
|
|
* | int | | Must be an integer |
|
|
* | float | | Must be a float |
|
|
* | bool | | Must be a boolean |
|
|
* | email | | Must be a valid email address |
|
|
* | url | | Must be a valid URL |
|
|
* | date | | Must be a parseable date |
|
|
* | regex | :pattern | Must match a PCRE pattern (with delimiters) |
|
|
* | enum | :a,b,c | Must be one of the listed values |
|
|
* | min | :n | Length/number/count min (files: KB) |
|
|
* | max | :n | Length/number/count max (files: KB) |
|
|
* | between | :min,:max | Value within range (files: KB per upload) |
|
|
* | size | :n | Exact length/number/count (files: KB) |
|
|
* | confirmed | | Must match the '<field>_confirmation' field |
|
|
* | required_with | :fields | Required when a listed field is present |
|
|
* | required_if | :field,:value | Required when a field equals a value |
|
|
* | file | | Value must be an uploaded file ($_FILES) |
|
|
* | image | | Uploaded file must be an image (real MIME) |
|
|
* | mimes | :ext,ext | Uploaded file's real MIME must match |
|
|
* |---------------+---------------+---------------------------------------------|
|
|
*
|
|
* @author KJ
|
|
* @website https://kj2.me
|
|
* @license MIT
|
|
*/
|
|
class Validator
|
|
{
|
|
/**
|
|
* Stores the last failed rule.
|
|
*
|
|
* @var string
|
|
*/
|
|
public static string $lastFailed = '';
|
|
|
|
/**
|
|
* Rules that need to know the current field name and the whole data set
|
|
* (e.g. to look up sibling fields such as the `confirmed` companion).
|
|
* For these rules, checkRule() injects $field and $haystack as extra
|
|
* arguments before the user-provided ones.
|
|
*
|
|
* @var string[]
|
|
*/
|
|
private const DATA_AWARE_RULES = ['confirmed', 'required_with', 'required_if'];
|
|
|
|
/**
|
|
* Rules whose mere presence marks a field as a "file field". When a field's
|
|
* rule list contains any of these, the shared rules below are routed to
|
|
* their file-specific variants (see checkRule / validateList).
|
|
*
|
|
* @var string[]
|
|
*/
|
|
private const FILE_MARKER_RULES = ['file', 'image', 'mimes'];
|
|
|
|
/**
|
|
* Shared rules that gain file semantics (kilobytes / upload presence) and
|
|
* are therefore dispatched to `file_<rule>()` for a file field.
|
|
*
|
|
* @var string[]
|
|
*/
|
|
private const FILE_AWARE_RULES = ['min', 'max', 'between', 'size', 'required', 'nullable'];
|
|
|
|
/**
|
|
* Default error message template for each rule, used by message() to build
|
|
* the text for the first failing rule.
|
|
*
|
|
* Supported placeholders:
|
|
* |------------+--------------------------------------------------|
|
|
* | Marker | Replaced with |
|
|
* |------------+--------------------------------------------------|
|
|
* | :attribute | attributes[campo] if given, else the field name |
|
|
* | :min | the min/between lower bound |
|
|
* | :max | the max/between upper bound |
|
|
* | :size | the size rule value |
|
|
* | :value | the required_if expected value |
|
|
* | :other | the required_if companion field / confirmation |
|
|
* | :values | a comma list (enum, mimes, required_with) |
|
|
* |----------+----------------------------------------------------|
|
|
*
|
|
* @var array<string,string>
|
|
*/
|
|
public static array $messageTemplates = [
|
|
'not' => 'The :attribute is not valid.',
|
|
'exists' => 'The :attribute field is missing.',
|
|
'required' => 'The :attribute field is required.',
|
|
'number' => 'The :attribute must be a number.',
|
|
'int' => 'The :attribute must be an integer.',
|
|
'float' => 'The :attribute must be a float.',
|
|
'bool' => 'The :attribute must be a boolean.',
|
|
'string' => 'The :attribute must be a string.',
|
|
'array' => 'The :attribute must be an array.',
|
|
'email' => 'The :attribute must be a valid email address.',
|
|
'url' => 'The :attribute must be a valid URL.',
|
|
'enum' => 'The selected :attribute is invalid. Allowed: :values.',
|
|
'min' => 'The :attribute must be at least :min.',
|
|
'max' => 'The :attribute must not be greater than :max.',
|
|
'between' => 'The :attribute must be between :min and :max.',
|
|
'size' => 'The :attribute must be exactly :size.',
|
|
'regex' => 'The :attribute format is invalid.',
|
|
'date' => 'The :attribute is not a valid date.',
|
|
'confirmed' => 'The :attribute confirmation does not match.',
|
|
'required_with' => 'The :attribute field is required when :values is present.',
|
|
'required_if' => 'The :attribute field is required when :other is :value.',
|
|
'nullable' => 'The :attribute field may be null.',
|
|
'file' => 'The :attribute must be a file.',
|
|
'image' => 'The :attribute must be an image.',
|
|
'mimes' => 'The :attribute must be a file of type: :values.',
|
|
];
|
|
|
|
/**
|
|
* Known MIME types per file extension, used by the mimes rule to compare
|
|
* the detected (real) MIME type of an upload against the allowed list.
|
|
*
|
|
* An extension maps to a set because different systems report slightly
|
|
* different types for the same format (e.g. "jpg" => image/jpeg/pjpeg).
|
|
* Public so applications can add formats without touching the library.
|
|
*
|
|
* @var array<string,string[]>
|
|
*/
|
|
public static array $mimeTypes = [
|
|
'jpg' => ['image/jpeg', 'image/pjpeg'],
|
|
'jpeg' => ['image/jpeg', 'image/pjpeg'],
|
|
'png' => ['image/png'],
|
|
'gif' => ['image/gif'],
|
|
'bmp' => ['image/bmp', 'image/x-ms-bmp'],
|
|
'webp' => ['image/webp'],
|
|
'svg' => ['image/svg+xml'],
|
|
'ico' => ['image/vnd.microsoft.icon', 'image/x-icon'],
|
|
'tif' => ['image/tiff'],
|
|
'tiff' => ['image/tiff'],
|
|
'pdf' => ['application/pdf'],
|
|
'txt' => ['text/plain'],
|
|
'csv' => ['text/plain', 'text/csv', 'application/csv'],
|
|
'json' => ['application/json'],
|
|
'html' => ['text/html'],
|
|
'xml' => ['text/xml', 'application/xml', 'text/plain'],
|
|
'mp3' => ['audio/mpeg', 'audio/mp3'],
|
|
'wav' => ['audio/wav', 'audio/x-wav'],
|
|
'ogg' => ['audio/ogg', 'application/ogg'],
|
|
'flac' => ['audio/flac', 'audio/x-flac'],
|
|
'mp4' => ['video/mp4'],
|
|
'm4a' => ['audio/mp4', 'video/mp4'],
|
|
'webm' => ['video/webm'],
|
|
'mov' => ['video/quicktime'],
|
|
'avi' => ['video/x-msvideo', 'video/avi'],
|
|
'zip' => ['application/zip', 'application/x-zip', 'application/x-zip-compressed'],
|
|
'gz' => ['application/gzip', 'application/x-gzip'],
|
|
'tar' => ['application/x-tar'],
|
|
'rar' => ['application/vnd.rar', 'application/x-rar-compressed', 'application/rar'],
|
|
'7z' => ['application/x-7z-compressed'],
|
|
'doc' => ['application/msword'],
|
|
'docx' => ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
|
|
'xls' => ['application/vnd.ms-excel'],
|
|
'xlsx' => ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
|
|
'ppt' => ['application/vnd.ms-powerpoint'],
|
|
'pptx' => ['application/vnd.openxmlformats-officedocument.presentationml.presentation'],
|
|
];
|
|
|
|
/**
|
|
* Builds the human-readable message for a single failed validation.
|
|
*
|
|
* Pure helper: it only reads its arguments and never reads nor writes
|
|
* $lastFailed or any other state. Request::validate() uses it to produce
|
|
* the single error message it sends, forwarding its messages() and
|
|
* attributes() maps; it can also be called directly by controllers.
|
|
*
|
|
* Resolution order for the message text:
|
|
* 1. $messages[$lastFailed] override by full "field.rule:args"
|
|
* 2. $messages["field.rule"] override by "field.rule" (no args)
|
|
* 3. $messageTemplates[rule] default for the rule
|
|
* Placeholders are then substituted in whichever text was chosen.
|
|
*
|
|
* @param string $lastFailed The "field.rule[:args]" produced by validateList().
|
|
* @param array $messages Optional per-field overrides.
|
|
* @param array $attributes Optional human field names ("field" => "Field").
|
|
*
|
|
* @return string
|
|
*/
|
|
public static function message(string $lastFailed, array $messages = [], array $attributes = []): string
|
|
{
|
|
$separator = strpos($lastFailed, '.');
|
|
|
|
if ($separator === false) {
|
|
return 'The :attribute is invalid.';
|
|
}
|
|
|
|
$target = substr($lastFailed, 0, $separator);
|
|
$ruleStr = substr($lastFailed, $separator + 1);
|
|
[$rule, $rawArguments] = static::parseRule($ruleStr);
|
|
|
|
$text = $messages[$lastFailed]
|
|
?? $messages[$target . '.' . $rule]
|
|
?? (static::$messageTemplates[$rule] ?? 'The :attribute is invalid.');
|
|
|
|
$replace = [
|
|
':attribute' => $attributes[$target] ?? $target,
|
|
];
|
|
|
|
$args = static::splitArguments($rule, $rawArguments);
|
|
|
|
switch ($rule) {
|
|
case 'min':
|
|
$replace[':min'] = $args[0] ?? '';
|
|
break;
|
|
case 'max':
|
|
$replace[':max'] = $args[0] ?? '';
|
|
break;
|
|
case 'between':
|
|
$replace[':min'] = $args[0] ?? '';
|
|
$replace[':max'] = $args[1] ?? '';
|
|
break;
|
|
case 'size':
|
|
$replace[':size'] = $args[0] ?? '';
|
|
break;
|
|
case 'enum':
|
|
case 'mimes':
|
|
case 'required_with':
|
|
$replace[':values'] = implode(', ', $args);
|
|
break;
|
|
case 'required_if':
|
|
$replace[':other'] = $args[0] ?? '';
|
|
$replace[':value'] = $args[1] ?? '';
|
|
break;
|
|
}
|
|
|
|
return strtr($text, $replace);
|
|
}
|
|
|
|
/**
|
|
* Validates a list of rules against the properties of an object.
|
|
*
|
|
* @param array $rulesList The list of rules.
|
|
* @param Neuron $haystack The object whose properties will be validated.
|
|
*
|
|
* @return bool Returns true only if all rules are met, and false as soon as one fails.
|
|
*/
|
|
public static function validateList(array $rulesList, Neuron $haystack): bool
|
|
{
|
|
foreach ($rulesList as $target => $rules) {
|
|
$rules = preg_split('/\|/', $rules);
|
|
$value = $haystack->{$target};
|
|
$ruleNames = array_map(static fn ($r) => static::parseRule($r)[0], $rules);
|
|
$isFileField = array_intersect($ruleNames, self::FILE_MARKER_RULES) !== [];
|
|
|
|
if (in_array('nullable', $ruleNames, true)) {
|
|
$empty = $isFileField
|
|
? !static::hasUploadedFile($value)
|
|
: static::isEmpty($value);
|
|
|
|
if ($empty) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
$ruleValidator = $isFileField ? 'checkFileRule' : 'checkRule';
|
|
|
|
foreach ($rules as $rule) {
|
|
if (static::$ruleValidator($value, $rule, $target, $haystack)) {
|
|
continue;
|
|
}
|
|
static::$lastFailed = $target . '.' . $rule;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Splits a single rule string into its name and its raw argument string.
|
|
*
|
|
* The rule name is everything before the first ':'. The remainder is
|
|
* returned verbatim (it is NOT split further here) so that rules whose
|
|
* arguments legitimately contain ':' or ',' - such as regex or mimes -
|
|
* are preserved intact. The caller decides how to split the arguments.
|
|
*
|
|
* @param string $rule The rule to parse. Ex: "regex:/^a,b$/" or "enum:a,b".
|
|
*
|
|
* @return array A two element array: [name, rawArguments]. When the rule
|
|
* has no parameters, rawArguments is an empty string.
|
|
*/
|
|
public static function parseRule(string $rule): array
|
|
{
|
|
$separator = strpos($rule, ':');
|
|
|
|
if ($separator === false) {
|
|
return [$rule, ''];
|
|
}
|
|
|
|
return [substr($rule, 0, $separator), substr($rule, $separator + 1)];
|
|
}
|
|
|
|
/**
|
|
* Checks if a scalar rule is met.
|
|
*
|
|
* @param mixed $subject The value to verify.
|
|
* @param string $rule The rule to test.
|
|
* @param string|null $field Optional name of the field being validated
|
|
* (used by "data-aware" rules to locate siblings).
|
|
* @param mixed $haystack Optional full data set (Neuron or array).
|
|
*
|
|
* @return bool
|
|
* @throws Exception If the rule is not callable.
|
|
*/
|
|
public static function checkRule(
|
|
mixed $subject,
|
|
string $rule,
|
|
?string $field = null,
|
|
mixed $haystack = null
|
|
): bool {
|
|
[$name, $rawArguments] = static::parseRule($rule);
|
|
|
|
return static::callRuleMethod($name, $rawArguments, $subject, $field, $haystack);
|
|
}
|
|
|
|
/**
|
|
* Variant of checkRule() for file fields.
|
|
*
|
|
* Shares the same dispatch but reroutes the size-based and presence rules
|
|
* (see FILE_AWARE_RULES) to their `file_*` counterparts, so min/max/between/
|
|
* size work on kilobytes and required works on uploads. Marker rules
|
|
* (file/image/mimes) and everything else run unchanged.
|
|
*
|
|
* @param mixed $subject The value to verify (a $_FILES entry).
|
|
* @param string $rule The rule to test.
|
|
* @param string|null $field Name of the field being validated.
|
|
* @param mixed $haystack The full data set (Neuron or array).
|
|
*
|
|
* @return bool
|
|
* @throws Exception If the rule is not callable.
|
|
*/
|
|
public static function checkFileRule(
|
|
mixed $subject,
|
|
string $rule,
|
|
?string $field = null,
|
|
mixed $haystack = null
|
|
): bool {
|
|
[$name, $rawArguments] = static::parseRule($rule);
|
|
|
|
if (in_array($name, self::FILE_AWARE_RULES, true)) {
|
|
$name = 'file_' . $name;
|
|
}
|
|
|
|
return static::callRuleMethod($name, $rawArguments, $subject, $field, $haystack);
|
|
}
|
|
|
|
/**
|
|
* Resolves a rule name to its method and invokes it with the right arguments.
|
|
*
|
|
* Data-aware rules receive the field name and haystack; every other rule gets
|
|
* just the subject plus its comma-split arguments.
|
|
*
|
|
* @param string $name The resolved rule/method name.
|
|
* @param string $rawArguments Raw argument string from parseRule().
|
|
* @param mixed $subject The value being validated.
|
|
* @param string|null $field Field name (for data-aware rules).
|
|
* @param mixed $haystack Full data set (for data-aware rules).
|
|
*
|
|
* @return bool
|
|
* @throws Exception If the rule is not callable.
|
|
*/
|
|
private static function callRuleMethod(
|
|
string $name,
|
|
string $rawArguments,
|
|
mixed $subject,
|
|
?string $field,
|
|
mixed $haystack
|
|
): bool {
|
|
$method = [static::class, $name];
|
|
|
|
if (in_array($name, self::DATA_AWARE_RULES, true)) {
|
|
$arguments = array_merge(
|
|
[$subject, $field, $haystack],
|
|
static::splitArguments($name, $rawArguments)
|
|
);
|
|
} else {
|
|
$arguments = array_merge([$subject], static::splitArguments($name, $rawArguments));
|
|
}
|
|
|
|
if (is_callable($method)) {
|
|
return call_user_func_array($method, $arguments);
|
|
}
|
|
|
|
throw new Exception('Bad rule: "' . $name . '"');
|
|
}
|
|
|
|
/**
|
|
* Splits a rule's raw argument string into the arguments to pass to the
|
|
* rule's method (the subject is not included).
|
|
*
|
|
* Most rules take a comma-separated list of values (e.g. "enum:a,b").
|
|
* A few rules receive an argument that must be kept intact because it can
|
|
* legitimately contain commas or colons: "regex" (a pattern) and "not"
|
|
* (a sub-rule that is itself parsed recursively). An empty argument string
|
|
* yields no extra arguments.
|
|
*
|
|
* @param string $name The rule name.
|
|
* @param string $raw The raw argument string as returned by parseRule().
|
|
*
|
|
* @return array The list of arguments to pass after the subject.
|
|
*/
|
|
private static function splitArguments(string $name, string $raw): array
|
|
{
|
|
if ($raw === '') {
|
|
return [];
|
|
}
|
|
|
|
$intactRules = ['regex', 'not'];
|
|
|
|
if (in_array($name, $intactRules, true)) {
|
|
return [$raw];
|
|
}
|
|
|
|
return explode(',', $raw);
|
|
}
|
|
|
|
/**
|
|
* Verifies the rule in a negative way.
|
|
*
|
|
* @param mixed $subject The value to verify.
|
|
* @param mixed $rule The rule to test.
|
|
*
|
|
* @return bool
|
|
*/
|
|
public static function not(mixed $subject, ...$rule): bool
|
|
{
|
|
return !static::checkRule($subject, join(':', $rule));
|
|
}
|
|
|
|
/**
|
|
* Checks if the value is defined/exists.
|
|
*
|
|
* @param mixed $subject The value to check.
|
|
*
|
|
* @return bool
|
|
*/
|
|
public static function exists(mixed $subject): bool
|
|
{
|
|
return isset($subject);
|
|
}
|
|
|
|
/**
|
|
* Checks if the value is defined and not empty.
|
|
*
|
|
* @param mixed $subject The value to check.
|
|
*
|
|
* @return bool
|
|
*/
|
|
public static function required(mixed $subject): bool
|
|
{
|
|
return isset($subject) && !empty($subject);
|
|
}
|
|
|
|
/**
|
|
* Checks if the value is numeric.
|
|
*
|
|
* @param mixed $subject The value to check.
|
|
*
|
|
* @return bool
|
|
*/
|
|
public static function number(mixed $subject): bool
|
|
{
|
|
return is_numeric($subject);
|
|
}
|
|
|
|
/**
|
|
* Checks if the value is an integer.
|
|
*
|
|
* @param mixed $subject The value to check.
|
|
*
|
|
* @return bool
|
|
*/
|
|
public static function int(mixed $subject): bool
|
|
{
|
|
return filter_var($subject, FILTER_VALIDATE_INT) !== false;
|
|
}
|
|
|
|
/**
|
|
* Checks if the value is a float.
|
|
*
|
|
* @param mixed $subject The value to check.
|
|
*
|
|
* @return bool
|
|
*/
|
|
public static function float(mixed $subject): bool
|
|
{
|
|
return filter_var($subject, FILTER_VALIDATE_FLOAT) !== false;
|
|
}
|
|
|
|
/**
|
|
* Checks if the value is a boolean.
|
|
*
|
|
* @param mixed $subject The value to check.
|
|
*
|
|
* @return bool
|
|
*/
|
|
public static function bool(mixed $subject): bool
|
|
{
|
|
return filter_var($subject, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== null;
|
|
}
|
|
|
|
/**
|
|
* Checks if the value is a string.
|
|
*
|
|
* Note this is the strict type check: a PHP integer (e.g. 42) or an array
|
|
* fails, while any string - including a numeric string like "42" - passes.
|
|
*
|
|
* @param mixed $subject The value to check.
|
|
*
|
|
* @return bool
|
|
*/
|
|
public static function string(mixed $subject): bool
|
|
{
|
|
return is_string($subject);
|
|
}
|
|
|
|
/**
|
|
* Checks if the value is an array.
|
|
*
|
|
* @param mixed $subject The value to check.
|
|
*
|
|
* @return bool
|
|
*/
|
|
public static function array(mixed $subject): bool
|
|
{
|
|
return is_array($subject);
|
|
}
|
|
|
|
/**
|
|
* Measures a value for the size-based rules (min/max/between/size).
|
|
*
|
|
* A numeric value is measured by its number, a plain string by its
|
|
* character length, and an array by its element count. This single helper
|
|
* keeps the size semantics consistent and is where the file case (kilobytes)
|
|
* is added later.
|
|
*
|
|
* @param mixed $subject The value to measure.
|
|
*
|
|
* @return float
|
|
*/
|
|
private static function measure(mixed $subject): float
|
|
{
|
|
if (is_numeric($subject)) {
|
|
return (float) $subject;
|
|
}
|
|
|
|
if (is_string($subject)) {
|
|
return (float) (function_exists('mb_strlen') ? mb_strlen($subject) : strlen($subject));
|
|
}
|
|
|
|
if (is_array($subject)) {
|
|
return (float) count($subject);
|
|
}
|
|
|
|
return 0.0;
|
|
}
|
|
|
|
/**
|
|
* Checks if the measured value is greater than or equal to a minimum.
|
|
*
|
|
* @param mixed $subject The value to check.
|
|
* @param mixed $min The minimum length, number or count.
|
|
*
|
|
* @return bool
|
|
*/
|
|
public static function min(mixed $subject, mixed $min): bool
|
|
{
|
|
return static::measure($subject) >= (float) $min;
|
|
}
|
|
|
|
/**
|
|
* Checks if the measured value is less than or equal to a maximum.
|
|
*
|
|
* @param mixed $subject The value to check.
|
|
* @param mixed $max The maximum length, number or count.
|
|
*
|
|
* @return bool
|
|
*/
|
|
public static function max(mixed $subject, mixed $max): bool
|
|
{
|
|
return static::measure($subject) <= (float) $max;
|
|
}
|
|
|
|
/**
|
|
* Checks if the measured value is within an inclusive range.
|
|
*
|
|
* @param mixed $subject The value to check.
|
|
* @param mixed $min The lower bound.
|
|
* @param mixed $max The upper bound.
|
|
*
|
|
* @return bool
|
|
*/
|
|
public static function between(mixed $subject, mixed $min, mixed $max): bool
|
|
{
|
|
$size = static::measure($subject);
|
|
|
|
return $size >= (float) $min && $size <= (float) $max;
|
|
}
|
|
|
|
/**
|
|
* Checks if the measured value is exactly a given size.
|
|
*
|
|
* @param mixed $subject The value to check.
|
|
* @param mixed $size The expected length, number or count.
|
|
*
|
|
* @return bool
|
|
*/
|
|
public static function size(mixed $subject, mixed $size): bool
|
|
{
|
|
return static::measure($subject) === (float) $size;
|
|
}
|
|
|
|
/**
|
|
* Checks if the value matches a regular expression.
|
|
*
|
|
* The pattern is received verbatim (delimiters included), thanks to
|
|
* splitArguments() treating "regex" as an intact argument, so patterns
|
|
* that contain ':' or ',' - such as "/^a,b$/" - work as expected.
|
|
*
|
|
* @param mixed $subject The value to check.
|
|
* @param string $pattern A PCRE pattern including its delimiters.
|
|
*
|
|
* @return bool
|
|
*/
|
|
public static function regex(mixed $subject, mixed $pattern): bool
|
|
{
|
|
return preg_match((string) $pattern, (string) $subject) === 1;
|
|
}
|
|
|
|
/**
|
|
* Checks if the value is a parseable date.
|
|
*
|
|
* Any format accepted by DateTime is valid (e.g. "2026-09-03", an ISO
|
|
* datetime, or a timestamp string). Empty and non-scalar values fail.
|
|
*
|
|
* @param mixed $subject The value to check.
|
|
*
|
|
* @return bool
|
|
*/
|
|
public static function date(mixed $subject): bool
|
|
{
|
|
if (!is_scalar($subject) || trim((string) $subject) === '') {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
new \DateTime((string) $subject);
|
|
|
|
return true;
|
|
} catch (Exception $e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Marks a field as allowed to be empty.
|
|
*
|
|
* As a rule it always passes; the real effect happens in validateList(),
|
|
* which skips all the other rules for a field when "nullable" is present
|
|
* and the value is empty (see isEmpty()).
|
|
*
|
|
* @param mixed $subject The value to check.
|
|
*
|
|
* @return bool Always true.
|
|
*/
|
|
public static function nullable(mixed $subject): bool
|
|
{
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Checks that the field has a matching companion "<field>_confirmation".
|
|
*
|
|
* @param mixed $subject The value to check.
|
|
* @param string|null $field Name of the field being validated.
|
|
* @param mixed $haystack The full data set (Neuron or array).
|
|
*
|
|
* @return bool
|
|
*/
|
|
public static function confirmed(mixed $subject, ?string $field = null, mixed $haystack = null): bool
|
|
{
|
|
if ($field === null || $haystack === null) {
|
|
return false;
|
|
}
|
|
|
|
return static::readField($haystack, $field . '_confirmation') === $subject;
|
|
}
|
|
|
|
// phpcs:disable PSR1.Methods.CamelCapsMethodName.NotCamelCaps
|
|
/**
|
|
* Requires the field only when at least one of the listed fields is present.
|
|
*
|
|
* @param mixed $subject The value to check.
|
|
* @param string $field Name of the field being validated (unused here).
|
|
* @param mixed $haystack The full data set (Neuron or array).
|
|
* @param string ...$others Names of the fields whose presence triggers the requirement.
|
|
*
|
|
* @return bool
|
|
*/
|
|
public static function required_with(
|
|
mixed $subject,
|
|
?string $field = null,
|
|
mixed $haystack = null,
|
|
string ...$others
|
|
): bool {
|
|
if ($haystack === null || empty($others)) {
|
|
return true;
|
|
}
|
|
|
|
foreach ($others as $other) {
|
|
if (!static::isEmpty(static::readField($haystack, $other))) {
|
|
return isset($subject) && !empty($subject);
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Requires the field only when another field equals a given value.
|
|
*
|
|
* @param mixed $subject The value to check.
|
|
* @param string $field Name of the field being validated (unused here).
|
|
* @param mixed $haystack The full data set (Neuron or array).
|
|
* @param string|null $other Name of the field to inspect.
|
|
* @param mixed $value Expected value that triggers the requirement.
|
|
*
|
|
* @return bool
|
|
*/
|
|
public static function required_if(
|
|
mixed $subject,
|
|
?string $field = null,
|
|
mixed $haystack = null,
|
|
?string $other = null,
|
|
mixed $value = null
|
|
): bool {
|
|
if ($haystack === null || $other === null) {
|
|
return true;
|
|
}
|
|
|
|
$otherValue = static::readField($haystack, $other);
|
|
|
|
if (is_scalar($otherValue) && (string) $otherValue === (string) $value) {
|
|
return isset($subject) && !empty($subject);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Checks if the value is a valid email address.
|
|
*
|
|
* @param mixed $subject The value to check.
|
|
*
|
|
* @return bool
|
|
*/
|
|
public static function email(mixed $subject): bool
|
|
{
|
|
return filter_var($subject, FILTER_VALIDATE_EMAIL) !== false;
|
|
}
|
|
|
|
/**
|
|
* Checks if the value is a valid URL.
|
|
*
|
|
* @param mixed $subject The value to check.
|
|
*
|
|
* @return bool
|
|
*/
|
|
public static function url(mixed $subject): bool
|
|
{
|
|
return filter_var($subject, FILTER_VALIDATE_URL) !== false;
|
|
}
|
|
|
|
/**
|
|
* Checks if the value is present in a list of allowed values.
|
|
*
|
|
* @param mixed $subject The value to check.
|
|
* @param mixed ...$values A variable number of allowed values.
|
|
*
|
|
* @return bool
|
|
*/
|
|
public static function enum(mixed $subject, ...$values): bool
|
|
{
|
|
return in_array($subject, $values);
|
|
}
|
|
|
|
/**
|
|
* Checks that the value contains at least one successfully uploaded file.
|
|
*
|
|
* Presence is enforced by this rule itself: if nothing was uploaded (or the
|
|
* value is not a file descriptor) it fails. Make a file field optional with
|
|
* the `nullable` rule. An upload that errored (size limit, partial, ...) also
|
|
* fails.
|
|
*
|
|
* @param mixed $subject The value to check (a $_FILES entry, single or multi).
|
|
*
|
|
* @return bool
|
|
*/
|
|
public static function file(mixed $subject): bool
|
|
{
|
|
$uploads = static::asFile($subject);
|
|
|
|
if ($uploads === null) {
|
|
return false;
|
|
}
|
|
|
|
$present = false;
|
|
|
|
foreach ($uploads as $upload) {
|
|
if ($upload['error'] === UPLOAD_ERR_NO_FILE) {
|
|
continue;
|
|
}
|
|
|
|
$present = true;
|
|
|
|
if ($upload['error'] !== UPLOAD_ERR_OK || $upload['name'] === '') {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return $present;
|
|
}
|
|
|
|
/**
|
|
* Checks that every uploaded file is an image (by its real MIME type).
|
|
*
|
|
* @param mixed $subject The value to check (a $_FILES entry, single or multi).
|
|
*
|
|
* @return bool
|
|
*/
|
|
public static function image(mixed $subject): bool
|
|
{
|
|
return static::everyUploadedMime(
|
|
$subject,
|
|
static fn (string $mime): bool => str_starts_with($mime, 'image/')
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Checks that every uploaded file matches one of the allowed extensions.
|
|
*
|
|
* The extension list is translated to the set of MIME types it stands for
|
|
* (via $mimeTypes) and compared against each file's real, sniffed MIME type
|
|
* - never the client-declared type nor the file name.
|
|
*
|
|
* @param mixed $subject The value to check (a $_FILES entry, single or multi).
|
|
* @param string ...$extensions Allowed extensions, e.g. "jpg", "png".
|
|
*
|
|
* @return bool
|
|
*/
|
|
public static function mimes(mixed $subject, ...$extensions): bool
|
|
{
|
|
$allowed = [];
|
|
|
|
foreach ($extensions as $extension) {
|
|
foreach (static::$mimeTypes[strtolower(trim($extension))] ?? [] as $mime) {
|
|
$allowed[$mime] = true;
|
|
}
|
|
}
|
|
|
|
$allowedTypes = array_keys($allowed);
|
|
|
|
return static::everyUploadedMime(
|
|
$subject,
|
|
static fn (string $mime): bool => in_array($mime, $allowedTypes, true)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* File-field variant of `min`: every uploaded file must be at least $min KB.
|
|
*
|
|
* @param mixed $subject The value to check (a $_FILES entry, single or multi).
|
|
* @param mixed $min Minimum size in kilobytes.
|
|
*
|
|
* @return bool
|
|
*/
|
|
public static function file_min(mixed $subject, mixed $min): bool
|
|
{
|
|
$min = (float) $min;
|
|
|
|
return static::everyUploadedSize($subject, static fn (float $kb): bool => $kb >= $min);
|
|
}
|
|
|
|
/**
|
|
* File-field variant of `max`: every uploaded file must be at most $max KB.
|
|
*
|
|
* @param mixed $subject The value to check (a $_FILES entry, single or multi).
|
|
* @param mixed $max Maximum size in kilobytes.
|
|
*
|
|
* @return bool
|
|
*/
|
|
public static function file_max(mixed $subject, mixed $max): bool
|
|
{
|
|
$max = (float) $max;
|
|
|
|
return static::everyUploadedSize($subject, static fn (float $kb): bool => $kb <= $max);
|
|
}
|
|
|
|
/**
|
|
* File-field variant of `between`: every uploaded file must be between $min
|
|
* and $max kilobytes.
|
|
*
|
|
* @param mixed $subject The value to check (a $_FILES entry, single or multi).
|
|
* @param mixed $min Lower bound in KB.
|
|
* @param mixed $max Upper bound in KB.
|
|
*
|
|
* @return bool
|
|
*/
|
|
public static function file_between(mixed $subject, mixed $min, mixed $max): bool
|
|
{
|
|
$min = (float) $min;
|
|
$max = (float) $max;
|
|
|
|
return static::everyUploadedSize(
|
|
$subject,
|
|
static fn (float $kb): bool => $kb >= $min && $kb <= $max
|
|
);
|
|
}
|
|
|
|
/**
|
|
* File-field variant of `size`: every uploaded file must be exactly $size KB.
|
|
*
|
|
* @param mixed $subject The value to check (a $_FILES entry, single or multi).
|
|
* @param mixed $size Exact size in kilobytes.
|
|
*
|
|
* @return bool
|
|
*/
|
|
public static function file_size(mixed $subject, mixed $size): bool
|
|
{
|
|
$size = (float) $size;
|
|
|
|
return static::everyUploadedSize($subject, static fn (float $kb): bool => $kb === $size);
|
|
}
|
|
|
|
/**
|
|
* File-field variant of `required`: at least one file must be uploaded.
|
|
*
|
|
* @param mixed $subject The value to check (a $_FILES entry, single or multi).
|
|
*
|
|
* @return bool
|
|
*/
|
|
public static function file_required(mixed $subject): bool
|
|
{
|
|
return static::hasUploadedFile($subject);
|
|
}
|
|
|
|
/**
|
|
* File-field marker for `nullable`.
|
|
*
|
|
* As a rule it always passes; the skip decision for a file field lives in
|
|
* validateList(), which treats a file field as empty when no upload came in
|
|
* (see hasUploadedFile()).
|
|
*
|
|
* @param mixed $subject The value to check.
|
|
*
|
|
* @return bool Always true.
|
|
*/
|
|
public static function file_nullable(mixed $subject): bool
|
|
{
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Reads a field from a data set that may be a Neuron object or an array.
|
|
*
|
|
* Returns null when the field is not defined. Neuron already yields null
|
|
* for undefined properties, so no notice is raised for either source.
|
|
*
|
|
* @param mixed $haystack The data set (Neuron or array).
|
|
* @param string $field The field name to read.
|
|
*
|
|
* @return mixed
|
|
*/
|
|
private static function readField(mixed $haystack, string $field): mixed
|
|
{
|
|
if (is_array($haystack)) {
|
|
return $haystack[$field] ?? null;
|
|
}
|
|
|
|
if (is_object($haystack)) {
|
|
return $haystack->{$field};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Determines whether a value counts as "empty" for optionality rules
|
|
* (nullable / required_with / required_if).
|
|
*
|
|
* Matches the conventional definition: null, an empty string or an empty
|
|
* array. Note that, unlike PHP's empty(), "0" and 0 are NOT treated as
|
|
* empty so they cannot silently bypass a required-style rule. File fields
|
|
* have their own emptiness predicate (hasUploadedFile) used by the file
|
|
* routing in validateList(); this helper stays scalar.
|
|
*
|
|
* @param mixed $value The value to test.
|
|
*
|
|
* @return bool
|
|
*/
|
|
private static function isEmpty(mixed $value): bool
|
|
{
|
|
return $value === null || $value === '' || $value === [];
|
|
}
|
|
|
|
/**
|
|
* Detects and normalizes a file upload value into a list of descriptors.
|
|
*
|
|
* A value is treated as an uploaded file when it is an array with the shape
|
|
* of a $_FILES entry, i.e. it defines at least the `name`, `tmp_name` and
|
|
* `error` keys. Both single uploads and multi-file uploads (input name="x[]",
|
|
* where every key is an array) are supported. Each returned descriptor always
|
|
* carries name, type, tmp_name, error and size with safe defaults, so the
|
|
* file rules can rely on every key. Non-file values return null.
|
|
*
|
|
* @param mixed $subject The value to inspect.
|
|
*
|
|
* @return array|null A list of normalized descriptors, or null when not a file.
|
|
*/
|
|
private static function asFile(mixed $subject): ?array
|
|
{
|
|
if (
|
|
!is_array($subject) ||
|
|
!isset($subject['name'], $subject['tmp_name'], $subject['error'])
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
if (is_array($subject['name'])) {
|
|
return static::asFileList($subject);
|
|
}
|
|
|
|
return [[
|
|
'name' => (string) $subject['name'],
|
|
'type' => (string) ($subject['type'] ?? ''),
|
|
'tmp_name' => (string) $subject['tmp_name'],
|
|
'error' => (int) $subject['error'],
|
|
'size' => (int) ($subject['size'] ?? 0),
|
|
]];
|
|
}
|
|
|
|
/**
|
|
* Zips a multi-file $_FILES entry (parallel arrays) into a list of single
|
|
* file descriptors. The number of files is taken from the `name` array and
|
|
* the other arrays are read index by index with safe defaults.
|
|
*
|
|
* @param array $subject The raw multi-file $_FILES entry.
|
|
*
|
|
* @return array The list of normalized descriptors.
|
|
*/
|
|
private static function asFileList(array $subject): array
|
|
{
|
|
$names = array_values($subject['name']);
|
|
$read = static function ($key, int $index) {
|
|
$values = is_array($key) ? array_values($key) : [];
|
|
|
|
return $values[$index] ?? null;
|
|
};
|
|
|
|
$files = [];
|
|
|
|
foreach ($names as $index => $name) {
|
|
$files[] = [
|
|
'name' => (string) $name,
|
|
'type' => (string) ($read($subject['type'] ?? [], $index) ?? ''),
|
|
'tmp_name' => (string) ($read($subject['tmp_name'], $index) ?? ''),
|
|
'error' => (int) ($read($subject['error'], $index) ?? UPLOAD_ERR_NO_FILE),
|
|
'size' => (int) ($read($subject['size'] ?? [], $index) ?? 0),
|
|
];
|
|
}
|
|
|
|
return $files;
|
|
}
|
|
|
|
/**
|
|
* Determines the real MIME type of an uploaded file by sniffing its content
|
|
* with finfo, ignoring the client-declared `type` and the file `name`.
|
|
*
|
|
* Returns null when the file cannot be inspected (empty/missing path or the
|
|
* fileinfo extension is unavailable).
|
|
*
|
|
* @param array $descriptor A normalized file descriptor from asFile().
|
|
*
|
|
* @return string|null
|
|
*/
|
|
private static function realMime(array $descriptor): ?string
|
|
{
|
|
$path = $descriptor['tmp_name'] ?? '';
|
|
|
|
if ($path === '' || !is_file($path) || !class_exists(\finfo::class)) {
|
|
return null;
|
|
}
|
|
|
|
$finfo = new \finfo(FILEINFO_MIME_TYPE);
|
|
$mime = $finfo->file($path);
|
|
|
|
return $mime === false ? null : $mime;
|
|
}
|
|
|
|
/**
|
|
* Applies a MIME predicate to every uploaded file of a value.
|
|
*
|
|
* Presence is enforced: fails when the value is not a file descriptor, when
|
|
* no upload was actually provided, when a present upload errored, or when a
|
|
* present upload's real (sniffed) MIME type is unknown or does not satisfy
|
|
* $check. Make a file field optional with the `nullable` rule.
|
|
*
|
|
* @param mixed $subject The value to inspect.
|
|
* @param callable $check Predicate receiving the real MIME type: fn(string): bool.
|
|
*
|
|
* @return bool
|
|
*/
|
|
private static function everyUploadedMime(mixed $subject, callable $check): bool
|
|
{
|
|
$uploads = static::asFile($subject);
|
|
|
|
if ($uploads === null) {
|
|
return false;
|
|
}
|
|
|
|
$present = false;
|
|
|
|
foreach ($uploads as $upload) {
|
|
if ($upload['error'] === UPLOAD_ERR_NO_FILE) {
|
|
continue;
|
|
}
|
|
|
|
$present = true;
|
|
|
|
if ($upload['error'] !== UPLOAD_ERR_OK || $upload['name'] === '') {
|
|
return false;
|
|
}
|
|
|
|
$mime = static::realMime($upload);
|
|
|
|
if ($mime === null || $check($mime) === false) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return $present;
|
|
}
|
|
|
|
/**
|
|
* Sizes (in KB) of the present uploads of a value, or null when it is not a
|
|
* file. Absent uploads are skipped, so a field with no file yields an empty
|
|
* list. Division is forced to float so exact-KB sizes compare cleanly.
|
|
*
|
|
* @param mixed $subject The value to measure.
|
|
*
|
|
* @return float[]|null
|
|
*/
|
|
private static function fileSizesKb(mixed $subject): ?array
|
|
{
|
|
$uploads = static::asFile($subject);
|
|
|
|
if ($uploads === null) {
|
|
return null;
|
|
}
|
|
|
|
$sizes = [];
|
|
|
|
foreach ($uploads as $upload) {
|
|
if ($upload['error'] === UPLOAD_ERR_NO_FILE || $upload['name'] === '') {
|
|
continue;
|
|
}
|
|
|
|
$sizes[] = (float) ($upload['size'] / 1024);
|
|
}
|
|
|
|
return $sizes;
|
|
}
|
|
|
|
/**
|
|
* Checks that the value is a file and that every present upload satisfies
|
|
* $check. Fails when the value is not a file descriptor; an empty set of
|
|
* present uploads passes (presence belongs to the file rules).
|
|
*
|
|
* @param mixed $subject The value to inspect.
|
|
* @param callable $check Predicate receiving a size in KB: fn(float): bool.
|
|
*
|
|
* @return bool
|
|
*/
|
|
private static function everyUploadedSize(mixed $subject, callable $check): bool
|
|
{
|
|
$sizes = static::fileSizesKb($subject);
|
|
|
|
if ($sizes === null) {
|
|
return false;
|
|
}
|
|
|
|
foreach ($sizes as $kb) {
|
|
if ($check($kb) === false) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Checks whether a value carries at least one successfully uploaded file.
|
|
*
|
|
* @param mixed $subject The value to inspect.
|
|
*
|
|
* @return bool
|
|
*/
|
|
private static function hasUploadedFile(mixed $subject): bool
|
|
{
|
|
$uploads = static::asFile($subject);
|
|
|
|
if ($uploads === null) {
|
|
return false;
|
|
}
|
|
|
|
foreach ($uploads as $upload) {
|
|
if ($upload['error'] !== UPLOAD_ERR_NO_FILE && $upload['name'] !== '') {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|