diff --git a/src/Libs/Request.php b/src/Libs/Request.php index 99bff58..122ceab 100644 --- a/src/Libs/Request.php +++ b/src/Libs/Request.php @@ -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; } diff --git a/src/Libs/Validator.php b/src/Libs/Validator.php index 79fe9e0..d3cf3b6 100644 --- a/src/Libs/Validator.php +++ b/src/Libs/Validator.php @@ -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_()` 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 + */ + 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; + } }