feat(validator): add file upload validation rules
This commit is contained in:
@@ -76,10 +76,17 @@ class Request extends Neuron
|
||||
default => $this->get
|
||||
};
|
||||
|
||||
// Merge uploaded files ($_FILES) into the body data set so the file rules
|
||||
// (file/image/mimes and the routed min/max/required...) can validate
|
||||
// uploads through a Request. $this->post is left untouched.
|
||||
$body = empty($_FILES)
|
||||
? $actual
|
||||
: new Neuron(array_merge(get_object_vars($actual), $_FILES));
|
||||
|
||||
if (
|
||||
Validator::validateList(static::paramRules(), $this->params) &&
|
||||
Validator::validateList(static::getRules(), $this->get) &&
|
||||
Validator::validateList(static::rules(), $actual)
|
||||
Validator::validateList(static::rules(), $body)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -52,6 +52,23 @@ class Validator
|
||||
*/
|
||||
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.
|
||||
@@ -99,6 +116,55 @@ class Validator
|
||||
'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.
|
||||
*
|
||||
@@ -182,13 +248,23 @@ class Validator
|
||||
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', $rules, true) && static::isEmpty($value)) {
|
||||
continue;
|
||||
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::checkRule($value, $rule, $target, $haystack)) {
|
||||
if (static::$ruleValidator($value, $rule, $target, $haystack)) {
|
||||
continue;
|
||||
}
|
||||
static::$lastFailed = $target . '.' . $rule;
|
||||
@@ -224,22 +300,81 @@ class Validator
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a rule is met.
|
||||
* 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.
|
||||
* Required by "data-aware" rules to locate sibling fields.
|
||||
* @param mixed $haystack Optional full data set (Neuron or array) the
|
||||
* subject belongs to. Also used by data-aware rules.
|
||||
* @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
|
||||
{
|
||||
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)) {
|
||||
@@ -658,6 +793,182 @@ class Validator
|
||||
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.
|
||||
*
|
||||
@@ -688,7 +999,9 @@ class Validator
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*
|
||||
@@ -698,4 +1011,223 @@ class Validator
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user