diff --git a/src/Illuminate/Validation/Rules/File.php b/src/Illuminate/Validation/Rules/File.php new file mode 100644 index 000000000000..7fc27e44ebe7 --- /dev/null +++ b/src/Illuminate/Validation/Rules/File.php @@ -0,0 +1,329 @@ + $mimetypes + * @return static + */ + public static function types($mimetypes) + { + return tap(new static(), fn ($file) => $file->allowedMimetypes = (array) $mimetypes); + } + + /** + * Indicate that the uploaded file should be exactly a certain size in kilobytes. + * + * @param int $kilobytes + * @return $this + */ + public function size($kilobytes) + { + $this->minimumFileSize = $kilobytes; + $this->maximumFileSize = $kilobytes; + + return $this; + } + + /** + * Indicate that the uploaded file should be between a minimum and maximum size in kilobytes. + * + * @param int $minKilobytes + * @param int $maxKilobytes + * @return $this + */ + public function between($minKilobytes, $maxKilobytes) + { + $this->minimumFileSize = $minKilobytes; + $this->maximumFileSize = $maxKilobytes; + + return $this; + } + + /** + * Indicate that the uploaded file should be no less than the given number of kilobytes. + * + * @param int $kilobytes + * @return $this + */ + public function min($kilobytes) + { + $this->minimumFileSize = $kilobytes; + + return $this; + } + + /** + * Indicate that the uploaded file should be no more than the given number of kilobytes. + * + * @param int $kilobytes + * @return $this + */ + public function max($kilobytes) + { + $this->maximumFileSize = $kilobytes; + + return $this; + } + + /** + * Specify additional validation rules that should be merged with the default rules during validation. + * + * @param string|array $rules + * @return $this + */ + public function rules($rules) + { + $this->customRules = array_merge($this->customRules, Arr::wrap($rules)); + + return $this; + } + + /** + * Determine if the validation rule passes. + * + * @param string $attribute + * @param mixed $value + * @return bool + */ + public function passes($attribute, $value) + { + $this->messages = []; + + $validator = Validator::make( + $this->data, + [$attribute => $this->buildValidationRules()], + $this->validator->customMessages, + $this->validator->customAttributes + ); + + if ($validator->fails()) { + return $this->fail($validator->messages()->all()); + } + + return true; + } + + /** + * Build the array of underlying validation rules based on the current state. + * + * @return array + */ + protected function buildValidationRules() + { + $rules = ['file']; + + $rules = array_merge($rules, $this->buildMimetypes()); + + $rules[] = match (true) { + is_null($this->minimumFileSize) && is_null($this->maximumFileSize) => null, + is_null($this->maximumFileSize) => "min:{$this->minimumFileSize}", + is_null($this->minimumFileSize) => "max:{$this->maximumFileSize}", + $this->minimumFileSize !== $this->maximumFileSize => "between:{$this->minimumFileSize},{$this->maximumFileSize}", + default => "size:{$this->minimumFileSize}", + }; + + return array_merge(array_filter($rules), $this->customRules); + } + + /** + * Separate given mimetypes from extensions and return an array of correct rules to validate against. + * + * @return array + */ + protected function buildMimetypes() + { + if (count($this->allowedMimetypes) === 0) { + return []; + } + + $rules = []; + + $mimetypes = array_filter( + $this->allowedMimetypes, + fn ($type) => str_contains($type, '/') + ); + + $mimes = array_diff($this->allowedMimetypes, $mimetypes); + + if (count($mimetypes) > 0) { + $rules[] = 'mimetypes:'.implode(',', $mimetypes); + } + + if (count($mimes) > 0) { + $rules[] = 'mimes:'.implode(',', $mimes); + } + + return $rules; + } + + /** + * Adds the given failures and return false. + * + * @param array|string $messages + * @return bool + */ + protected function fail($messages) + { + $messages = collect(Arr::wrap($messages))->map(function ($message) { + return $this->validator->getTranslator()->get($message); + })->all(); + + $this->messages = array_merge($this->messages, $messages); + + return false; + } + + /** + * Get the validation error message. + * + * @return array + */ + public function message() + { + return $this->messages; + } + + /** + * Set the performing validator. + * + * @param \Illuminate\Contracts\Validation\Validator $validator + * @return $this + */ + public function setValidator($validator) + { + $this->validator = $validator; + + return $this; + } + + /** + * Set the data under validation. + * + * @param array $data + * @return $this + */ + public function setData($data) + { + $this->data = $data; + + return $this; + } +} diff --git a/src/Illuminate/Validation/Rules/ImageFile.php b/src/Illuminate/Validation/Rules/ImageFile.php new file mode 100644 index 000000000000..2cee97e5bc00 --- /dev/null +++ b/src/Illuminate/Validation/Rules/ImageFile.php @@ -0,0 +1,28 @@ +rules('image'); + } + + /** + * The dimension constraints for the uploaded file. + * + * @param \Illuminate\Validation\Rules\Dimensions $dimensions + */ + public function dimensions($dimensions) + { + $this->rules($dimensions); + + return $this; + } +} diff --git a/tests/Validation/ValidationFileRuleTest.php b/tests/Validation/ValidationFileRuleTest.php new file mode 100644 index 000000000000..77feb39ed6d0 --- /dev/null +++ b/tests/Validation/ValidationFileRuleTest.php @@ -0,0 +1,295 @@ +fails( + File::default(), + 'foo', + ['validation.file'], + ); + + $this->passes( + File::default(), + UploadedFile::fake()->create('foo.bar'), + ); + + $this->passes(File::default(), null); + } + + protected function fails($rule, $values, $messages) + { + $this->assertValidationRules($rule, $values, false, $messages); + } + + protected function assertValidationRules($rule, $values, $result, $messages) + { + $values = Arr::wrap($values); + + foreach ($values as $value) { + $v = new Validator( + resolve('translator'), + ['my_file' => $value], + ['my_file' => is_object($rule) ? clone $rule : $rule] + ); + + $this->assertSame($result, $v->passes()); + + $this->assertSame( + $result ? [] : ['my_file' => $messages], + $v->messages()->toArray() + ); + } + } + + protected function passes($rule, $values) + { + $this->assertValidationRules($rule, $values, true, []); + } + + public function testSingleMimetype() + { + $this->fails( + File::types('text/plain'), + UploadedFile::fake()->createWithContent('foo.png', file_get_contents(__DIR__.'/fixtures/image.png')), + ['validation.mimetypes'] + ); + + $this->passes( + File::types('image/png'), + UploadedFile::fake()->createWithContent('foo.png', file_get_contents(__DIR__.'/fixtures/image.png')), + ); + } + + public function testMultipleMimeTypes() + { + $this->fails( + File::types(['text/plain', 'image/jpeg']), + UploadedFile::fake()->createWithContent('foo.png', file_get_contents(__DIR__.'/fixtures/image.png')), + ['validation.mimetypes'] + ); + + $this->passes( + File::types(['text/plain', 'image/png']), + UploadedFile::fake()->createWithContent('foo.png', file_get_contents(__DIR__.'/fixtures/image.png')), + ); + } + + public function testSingleMime() + { + $this->fails( + File::types('txt'), + UploadedFile::fake()->createWithContent('foo.png', file_get_contents(__DIR__.'/fixtures/image.png')), + ['validation.mimes'] + ); + + $this->passes( + File::types('png'), + UploadedFile::fake()->createWithContent('foo.png', file_get_contents(__DIR__.'/fixtures/image.png')), + ); + } + + public function testMultipleMimes() + { + $this->fails( + File::types(['png', 'jpg', 'jpeg', 'svg']), + UploadedFile::fake()->createWithContent('foo.txt', 'Hello World!'), + ['validation.mimes'] + ); + + $this->passes( + File::types(['png', 'jpg', 'jpeg', 'svg']), + [ + UploadedFile::fake()->createWithContent('foo.png', file_get_contents(__DIR__.'/fixtures/image.png')), + UploadedFile::fake()->createWithContent('foo.svg', file_get_contents(__DIR__.'/fixtures/image.svg')), + ] + ); + } + + public function testMixOfMimetypesAndMimes() + { + $this->fails( + File::types(['png', 'image/png']), + UploadedFile::fake()->createWithContent('foo.txt', 'Hello World!'), + ['validation.mimetypes', 'validation.mimes'] + ); + + $this->passes( + File::types(['png', 'image/png']), + UploadedFile::fake()->createWithContent('foo.png', file_get_contents(__DIR__.'/fixtures/image.png')), + ); + } + + public function testImage() + { + $this->fails( + File::image(), + UploadedFile::fake()->createWithContent('foo.txt', 'Hello World!'), + ['validation.image'] + ); + + $this->passes( + File::image(), + UploadedFile::fake()->image('foo.png'), + ); + } + + public function testSize() + { + $this->fails( + File::default()->size(1024), + [ + UploadedFile::fake()->create('foo.txt', 1025), + UploadedFile::fake()->create('foo.txt', 1023), + ], + ['validation.size.file'] + ); + + $this->passes( + File::default()->size(1024), + UploadedFile::fake()->create('foo.txt', 1024), + ); + } + + public function testBetween() + { + $this->fails( + File::default()->between(1024, 2048), + [ + UploadedFile::fake()->create('foo.txt', 1023), + UploadedFile::fake()->create('foo.txt', 2049), + ], + ['validation.between.file'] + ); + + $this->passes( + File::default()->between(1024, 2048), + [ + UploadedFile::fake()->create('foo.txt', 1024), + UploadedFile::fake()->create('foo.txt', 2048), + UploadedFile::fake()->create('foo.txt', 1025), + UploadedFile::fake()->create('foo.txt', 2047), + ] + ); + } + + public function testMin() + { + $this->fails( + File::default()->min(1024), + UploadedFile::fake()->create('foo.txt', 1023), + ['validation.min.file'] + ); + + $this->passes( + File::default()->min(1024), + [ + UploadedFile::fake()->create('foo.txt', 1024), + UploadedFile::fake()->create('foo.txt', 1025), + UploadedFile::fake()->create('foo.txt', 2048), + ] + ); + } + + public function testMax() + { + $this->fails( + File::default()->max(1024), + UploadedFile::fake()->create('foo.txt', 1025), + ['validation.max.file'] + ); + + $this->passes( + File::default()->max(1024), + [ + UploadedFile::fake()->create('foo.txt', 1024), + UploadedFile::fake()->create('foo.txt', 1023), + UploadedFile::fake()->create('foo.txt', 512), + ] + ); + } + + public function testMacro() + { + File::macro('toDocument', function () { + return static::default()->rules('mimes:txt,csv'); + }); + + $this->fails( + File::toDocument(), + UploadedFile::fake()->create('foo.png'), + ['validation.mimes'] + ); + + $this->passes( + File::toDocument(), + [ + UploadedFile::fake()->create('foo.txt'), + UploadedFile::fake()->create('foo.csv'), + ] + ); + } + + public function testItCanSetDefaultUsing() + { + $this->assertInstanceOf(File::class, File::default()); + + File::defaults(function () { + return File::types('txt')->max(12 * 1024); + }); + + $this->fails( + File::default(), + UploadedFile::fake()->create('foo.png', 13 * 1024), + [ + 'validation.mimes', + 'validation.max.file', + ] + ); + + File::defaults(File::image()->between(1024, 2048)); + + $this->passes( + File::default(), + UploadedFile::fake()->create('foo.png', 1.5 * 1024), + ); + } + + protected function setUp(): void + { + $container = Container::getInstance(); + + $container->bind('translator', function () { + return new Translator( + new ArrayLoader, 'en' + ); + }); + + Facade::setFacadeApplication($container); + + (new ValidationServiceProvider($container))->register(); + } + + protected function tearDown(): void + { + Container::setInstance(null); + + Facade::clearResolvedInstances(); + + Facade::setFacadeApplication(null); + } +} diff --git a/tests/Validation/ValidationImageFileRuleTest.php b/tests/Validation/ValidationImageFileRuleTest.php new file mode 100644 index 000000000000..187848b8fa98 --- /dev/null +++ b/tests/Validation/ValidationImageFileRuleTest.php @@ -0,0 +1,86 @@ +fails( + File::image()->dimensions(Rule::dimensions()->width(100)->height(100)), + UploadedFile::fake()->image('foo.png', 101, 101), + ['validation.dimensions'], + ); + + $this->passes( + File::image()->dimensions(Rule::dimensions()->width(100)->height(100)), + UploadedFile::fake()->image('foo.png', 100, 100), + ); + } + + protected function fails($rule, $values, $messages) + { + $this->assertValidationRules($rule, $values, false, $messages); + } + + protected function assertValidationRules($rule, $values, $result, $messages) + { + $values = Arr::wrap($values); + + foreach ($values as $value) { + $v = new Validator( + resolve('translator'), + ['my_file' => $value], + ['my_file' => is_object($rule) ? clone $rule : $rule] + ); + + $this->assertSame($result, $v->passes()); + + $this->assertSame( + $result ? [] : ['my_file' => $messages], + $v->messages()->toArray() + ); + } + } + + protected function passes($rule, $values) + { + $this->assertValidationRules($rule, $values, true, []); + } + + protected function setUp(): void + { + $container = Container::getInstance(); + + $container->bind('translator', function () { + return new Translator( + new ArrayLoader, 'en' + ); + }); + + Facade::setFacadeApplication($container); + + (new ValidationServiceProvider($container))->register(); + } + + protected function tearDown(): void + { + Container::setInstance(null); + + Facade::clearResolvedInstances(); + + Facade::setFacadeApplication(null); + } +}