diff --git a/README.md b/README.md index 41a8c42..32a3119 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,24 @@ $builder // etc. ``` +### File Uploads + +As of 2.3, Hyrule supports specifying rules for file uploads: + +```php +$builder + ->file('attachment') + ->required() + ->mime('image', 'video', 'text') + ->end() + // etc. + +``` + +See the following detailed guides on how to validate file uploads by file-type (MIME type), dimensions, etc. + +* [File Upload Validation - Images](./docs/file-upload-validation-images.md) +* [File Upload Validation - Other MIME Types](./docs/file-upload-validation-mime-types.md) ## Rules API diff --git a/docs/file-upload-validation-images.md b/docs/file-upload-validation-images.md new file mode 100644 index 0000000..349f492 --- /dev/null +++ b/docs/file-upload-validation-images.md @@ -0,0 +1,37 @@ +## File Upload Validation - Images + +Here are a few examples for how you can validate image uploads. + +### Validate dimensions + +```php + +$builder = Hyrule::create() + ->file('avatar') + ->required() + ->image() // Validates that upload is an image. + ->dimensions() // Starts dimension constraints... + ->ratio(1) + ->maxWidth(1000) + ->end() // Ends dimension rule-set. + ->end() // Ends the "avatar" field. + // ... + +``` + +See [`Dimensions`](https://github.com/laravel/framework/blob/9.x/src/Illuminate/Validation/Rules/Dimensions.php) class for all available constraints. + +### Only accept subset of image types + +```php +$builder = Hyrule::create() + ->file('avatar') + ->required() + ->mimeType() // Starts MIME-type constriants... + ->image('jpeg', 'gif', 'png') // Only accept image/{jpeg,gif,png} + ->end() // End MIME-Type constraints. + ->end() // End the "avatar" field. + // ... +``` + +See [File Upload Validation - MIME Types](./file-upload-validation-mime-types.md) for a comprehensive guide on MIME-Type rules. \ No newline at end of file diff --git a/docs/file-upload-validation-mime-types.md b/docs/file-upload-validation-mime-types.md new file mode 100644 index 0000000..c01d814 --- /dev/null +++ b/docs/file-upload-validation-mime-types.md @@ -0,0 +1,41 @@ +## File Upload Validation - MIME Types + +It is considered best practice to validate the MIME type of uploaded files. Here are a few examples on how to do that with Hyrule: + +```php +$builder = Hyrule::create() + ->file('attachment') + ->mimeType() // Starts MIME-Type contraints + /* + * All 5 top-level MIME type categories are supported + */ + ->application('pdf') // Allows application/pdf + ->image('jpg', 'png', ...) // Variadic. Enumerate sub-types e.g. image/jpeg, image/png, etc. + ->video('mp4', 'webm') + ->multipart(...) + ->message(...) + ->end() // Ends MIME Type constraint. + ->end() // Ends "attachment" field + // ... +``` + +Use `->allow(...)` to enumerate specific specific MIME-types: + +```php +$builder = Hyrule::create() + ->array('attachments') + ->between(1, 10) + ->each('file') + ->mimeType() + ->allow('application/pdf') + ->allow('image/jpg') + ->allow('image/png') + ->allow('image/svg') + ->allow('video/mp4') + // etc. + ->end() + ->end() + ->end() + // ... + +``` \ No newline at end of file diff --git a/src/Nodes/AbstractNode.php b/src/Nodes/AbstractNode.php index 3fb5440..b508901 100644 --- a/src/Nodes/AbstractNode.php +++ b/src/Nodes/AbstractNode.php @@ -10,6 +10,7 @@ use Square\Hyrule\Build\LazyRuleStringify; use Square\Hyrule\Path; use Square\Hyrule\PathExp; +use Stringable; /** * @method $this|AbstractNode|ArrayNode|ScalarNode|ObjectNode required() @@ -28,7 +29,7 @@ abstract class AbstractNode { /** - * @var array|string[]|LazyRuleStringify[]|Rule[] + * @var array|string[]|LazyRuleStringify[]|Rule[]|Stringable[] */ protected array $rules = []; @@ -137,10 +138,10 @@ public function requiredWith(string|PathExp|AbstractNode $path): self } /** - * @param string|Rule $rule - * @return $this|AbstractNode|ScalarNode|ObjectNode|ArrayNode + * @param string|Rule|Stringable $rule + * @return AbstractNode */ - public function rule(string|Rule $rule): self + public function rule(string|Rule|Stringable $rule): self { $this->rules[] = $rule; return $this; @@ -205,6 +206,8 @@ public function build(): array $rule = (string) $rule->setNode($this)->stringify(); } else if ($rule instanceof self) { $rule = (string) (new Path($rule))->pathName(); + } else if ($rule instanceof Stringable) { + $rule = $rule->__toString(); } return $rule; }, $this->rules); diff --git a/src/Nodes/ArrayNode.php b/src/Nodes/ArrayNode.php index 3f784f7..2caff92 100644 --- a/src/Nodes/ArrayNode.php +++ b/src/Nodes/ArrayNode.php @@ -9,9 +9,6 @@ use InvalidArgumentException; use LogicException; -/** - * @property bool $allowUnknownProperties - */ class ArrayNode extends CompoundNode { /** @@ -31,6 +28,7 @@ class ArrayNode extends CompoundNode 'array' => ArrayNode::class, 'object' => ObjectNode::class, 'scalar' => ScalarNode::class, + 'file' => FileNode::class, ]; /** @@ -45,7 +43,7 @@ public function __construct(string $name, ?CompoundNode $parent = null) /** * @param string $type - * @return AbstractNode|ArrayNode|ObjectNode + * @return AbstractNode|ArrayNode|ObjectNode|FileNode */ public function each(string $type): AbstractNode { diff --git a/src/Nodes/FileNode.php b/src/Nodes/FileNode.php new file mode 100644 index 0000000..f7a1f30 --- /dev/null +++ b/src/Nodes/FileNode.php @@ -0,0 +1,48 @@ +dimensions)) { + // Create a new Dimensions rule object, push it to the rules array, and + // keep a reference so we can modify it when we need to in the future. + $this->rule($this->dimensions = new Dimensions($this)); + } + return $this->dimensions; + } + + /** + * @return $this + */ + public function image(): FileNode + { + $this->rule('image'); + return $this; + } + + /** + * @return MIMEType + */ + public function mimeType(): MIMEType + { + if (!isset($this->mimeType)) { + // Create a new MIMEType rule object, push it to the rules array, and + // keep a reference so we can modify it when we need to in the future. + $this->rule($this->mimeType = new MIMEType($this)); + } + return $this->mimeType; + } +} \ No newline at end of file diff --git a/src/Nodes/ObjectNode.php b/src/Nodes/ObjectNode.php index 722c652..49960c5 100644 --- a/src/Nodes/ObjectNode.php +++ b/src/Nodes/ObjectNode.php @@ -88,6 +88,15 @@ public function array(string $name): ArrayNode return $this->registerNode(new ArrayNode($name, $this)); } + /** + * @param string $name + * @return FileNode + */ + public function file(string $name): FileNode + { + return $this->registerNode(new FileNode($name, $this)); + } + /** * @param string $name * @return ObjectNode @@ -195,6 +204,18 @@ public function arrayWith(string $name, callable $callable): self return $this; } + /** + * @param string $name + * @param callable $callable + * @return $this + */ + public function fileWith(string $name, callable $callable): self + { + $this->registerNode(new FileNode($name, $this)) + ->with($callable); + return $this; + } + /** * @param string $name * @param callable $callable diff --git a/src/PHPStan/ArrayNodeEachDynamicReturnExtension.php b/src/PHPStan/ArrayNodeEachDynamicReturnExtension.php index 5959c66..68c9238 100644 --- a/src/PHPStan/ArrayNodeEachDynamicReturnExtension.php +++ b/src/PHPStan/ArrayNodeEachDynamicReturnExtension.php @@ -13,6 +13,7 @@ use PHPStan\Type\Type; use Square\Hyrule\Nodes\ArrayNode; use Square\Hyrule\Nodes\BooleanNode; +use Square\Hyrule\Nodes\FileNode; use Square\Hyrule\Nodes\FloatNode; use Square\Hyrule\Nodes\IntegerNode; use Square\Hyrule\Nodes\NumericNode; @@ -65,6 +66,8 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method return new ObjectType(NumericNode::class); case "boolean": return new ObjectType(BooleanNode::class); + case "file": + return new ObjectType(FileNode::class); default: return null; } diff --git a/src/Rules/Dimensions.php b/src/Rules/Dimensions.php new file mode 100644 index 0000000..37c6699 --- /dev/null +++ b/src/Rules/Dimensions.php @@ -0,0 +1,29 @@ + $constraints + */ + public function __construct(FileNode $node, array $constraints = []) + { + $this->node = $node; + parent::__construct($constraints); + } + + public function end(): FileNode + { + return $this->node; + } +} \ No newline at end of file diff --git a/src/Rules/MIMEType.php b/src/Rules/MIMEType.php new file mode 100644 index 0000000..5cc0ecf --- /dev/null +++ b/src/Rules/MIMEType.php @@ -0,0 +1,163 @@ +node = $node; + } + + /** + * @return $this + */ + public function pdf(): self + { + return $this->allow('application/pdf'); + } + + /** + * @return $this + */ + public function json(): self + { + return $this->allow('application/json'); + } + + /** + * @param string $subType + * @param string ...$subTypes + * @return $this + */ + public function text(string $subType, string ...$subTypes): self + { + array_unshift($subTypes, $subType); + $this->typeAndSubtypes('text', $subTypes); + return $this; + } + + /** + * @param string $subType + * @param string ...$subTypes + * @return $this + */ + public function image(string $subType, string ...$subTypes): self + { + array_unshift($subTypes, $subType); + $this->typeAndSubtypes('image', $subTypes); + return $this; + } + + /** + * @param string $subType + * @param string ...$subTypes + * @return $this + */ + public function audio(string $subType, string ...$subTypes): self + { + array_unshift($subTypes, $subType); + $this->typeAndSubtypes('audio', $subTypes); + return $this; + } + + /** + * @param string $subType + * @param string ...$subTypes + * @return $this + */ + public function video(string $subType, string ...$subTypes): self + { + array_unshift($subTypes, $subType); + $this->typeAndSubtypes('video', $subTypes); + return $this; + } + + /** + * @param string $subType + * @param string ...$subTypes + * @return $this + */ + public function application(string $subType, string ...$subTypes): self + { + array_unshift($subTypes, $subType); + $this->typeAndSubtypes('application', $subTypes); + return $this; + } + + /** + * @param string $subType + * @param string ...$subTypes + * @return $this + */ + public function multipart(string $subType, string ...$subTypes): self + { + array_unshift($subTypes, $subType); + $this->typeAndSubtypes('multipart', $subTypes); + return $this; + } + + /** + * @param string $subType + * @param string ...$subTypes + * @return $this + */ + public function message(string $subType, string ...$subTypes): self + { + array_unshift($subTypes, $subType); + $this->typeAndSubtypes('message', $subTypes); + return $this; + } + + /** + * @param string $type + * @param array $subTypes + * @return $this + */ + protected function typeAndSubtypes(string $type, array $subTypes): self + { + foreach ($subTypes as $subType) { + $this->allow(sprintf('%s/%s', $type, $subType)); + } + return $this; + } + + /** + * @param string $type + * @return $this + */ + public function allow(string $type): self + { + $this->mimeTypes[$type] = $type; + return $this; + } + + public function __toString() + { + if (empty($this->mimeTypes)) { + return ''; + } + + return sprintf('mimetypes:%s', implode(',', array_values($this->mimeTypes))); + } + + /** + * @return FileNode + */ + public function end(): FileNode + { + return $this->node; + } +} \ No newline at end of file diff --git a/tests/ArrayNodeTest.php b/tests/ArrayNodeTest.php index 7c97975..a58e327 100644 --- a/tests/ArrayNodeTest.php +++ b/tests/ArrayNodeTest.php @@ -3,6 +3,7 @@ namespace Square\Hyrule\Tests; use Generator; +use Illuminate\Http\UploadedFile; use Square\Hyrule\Nodes\ArrayNode; class ArrayNodeTest extends NodeTestAbstract @@ -80,6 +81,21 @@ static function (ArrayNode $node) { ->each('integer')->end(); } ]; + + yield 'array of images' => [ + [ + UploadedFile::fake()->image('foo.jpeg'), + UploadedFile::fake()->image('foo.jpg'), + UploadedFile::fake()->image('foo.png'), + UploadedFile::fake()->image('foo.gif'), + UploadedFile::fake()->image('foo.svg'), + UploadedFile::fake()->image('foo.bmp'), + ], + static function(ArrayNode $node) { + $node->each('file') + ->image(); + } + ]; } /** @@ -139,5 +155,19 @@ static function (ArrayNode $node) { ->min(1); } ]; + + yield 'array of files' => [ + [ + UploadedFile::fake()->create('foo.pdf', 0, 'application/pdf'), + UploadedFile::fake()->image('foo.jpeg'), + UploadedFile::fake()->create('foo.json', 0, 'application/json'), + ], + static function(ArrayNode $node) { + $node->each('file') + ->mimeType() + ->application('pdf') + ->image('jpeg'); + } + ]; } } diff --git a/tests/FileNodeTest.php b/tests/FileNodeTest.php new file mode 100644 index 0000000..76dddff --- /dev/null +++ b/tests/FileNodeTest.php @@ -0,0 +1,230 @@ + [ + UploadedFile::fake()->image('foo.jpg'), + static function(FileNode $node) { + return $node->image(); + }, + ]; + + yield 'image: matches max width & height' => [ + UploadedFile::fake()->image('foo.jpg', 800, 600), + static function(FileNode $node) { + $node->dimensions()->maxWidth(800)->maxHeight(600); + }, + ]; + + yield 'image: smaller than max width & height' => [ + UploadedFile::fake()->image('foo.jpg', 800, 600), + static function(FileNode $node) { + $node->dimensions()->maxWidth(1000)->maxHeight(1000); + } + ]; + + yield 'image: exact dimensions' => [ + UploadedFile::fake()->image('foo.jpg', 1024, 768), + static function(FileNode $node) { + $node->dimensions()->width(1024)->height(768); + }, + ]; + + yield 'image: ratio' => [ + UploadedFile::fake()->image('foo.jpg', 800, 800), + static function(FileNode $node) { + $node->dimensions()->ratio(1); + } + ]; + + yield 'mime-type: pdf' => [ + UploadedFile::fake()->create('foo.pdf', 0, 'application/pdf'), + static function(FileNode $node) { + $node->mimeType()->pdf(); + } + ]; + + yield 'mime-type: json' => [ + UploadedFile::fake()->create('foo.json', 0, 'application/json'), + static function(FileNode $node) { + $node->mimeType()->json(); + } + ]; + + yield 'mime-type: text/plain' => [ + UploadedFile::fake()->create('foo.txt', 0, 'text/plain'), + static function(FileNode $node) { + $node->mimeType()->text('plain'); + } + ]; + + yield 'mime-type explicit: text/plain' => [ + UploadedFile::fake()->create('foo.txt', 0, 'text/plain'), + static function(FileNode $node) { + $node->mimeType()->allow('text/plain'); + }, + ]; + } + public function dataInvalid() + { + yield 'image' => [ + UploadedFile::fake()->create('foo.txt'), + static function(FileNode $node) { + return $node->image(); + }, + ]; + + yield 'image: too large' => [ + UploadedFile::fake()->image('foo.jpg', 800, 600), + static function(FileNode $node) { + $node->dimensions()->maxWidth(500)->maxHeight(600); + }, + ]; + + yield 'image: too small' => [ + UploadedFile::fake()->image('foo.jpg', 800, 600), + static function(FileNode $node) { + $node->dimensions()->minHeight(1000)->minWidth(1000); + } + ]; + + yield 'image: does not match dimensions' => [ + UploadedFile::fake()->image('foo.jpg', 800, 600), + static function(FileNode $node) { + $node->dimensions()->width(1024)->height(768); + }, + ]; + + yield 'image: does not match ratio' => [ + UploadedFile::fake()->image('foo.jpg', 800, 800), + static function(FileNode $node) { + $node->dimensions()->ratio(0.5); + } + ]; + + yield 'mime-type: not pdf' => [ + UploadedFile::fake()->create('foo.txt'), + static function(FileNode $node) { + $node->mimeType()->pdf(); + } + ]; + + yield 'mime-type: not json' => [ + UploadedFile::fake()->create('foo.pdf'), + static function(FileNode $node) { + $node->mimeType()->json(); + } + ]; + } + + protected function getNodeClassName(): string + { + return FileNode::class; + } + + protected function defaultRules(): array + { + return []; + } + + public function testDimensionsFluentAPI(): void + { + $file = Hyrule::create()->file('file'); + $dimensions = $file->dimensions(); + $this->assertInstanceOf(Dimensions::class, $dimensions); + $this->assertSame($dimensions, $file->dimensions()); + $this->assertSame($file, $dimensions->end()); + } + + public function testMIMETypeFluentAPI(): void + { + $file = Hyrule::create()->file('file'); + $mime = $file->mimeType(); + $this->assertInstanceOf(MIMEType::class, $mime); + $this->assertSame($mime, $file->mimeType()); + $this->assertSame($file, $mime->end()); + } + + /** + * @param array $expectedRules + * @param callable $callback + * @dataProvider dataBuiltRules + * @return void + */ + public function testBuiltRules(array $expectedRules, callable $callback): void + { + $file = Hyrule::create()->file('file'); + $callback($file); + $this->assertEquals($expectedRules, $file->build()['file']); + } + + /** + * @return Generator + */ + public function dataBuiltRules(): Generator + { + yield 'dimensions: max width & height' => [ + ['dimensions:max_width=100,max_height=100'], + static function(FileNode $node) { + $node->dimensions()->maxWidth(100)->maxHeight(100); + }, + ]; + + yield 'dimensions: ratio' => [ + ['dimensions:ratio=0.5'], + static function(FileNode $node) { + $node->dimensions()->ratio(0.5); + }, + ]; + + yield 'mime-type: allow multiple' => [ + ['mimetypes:text/plain,application/json,image/jpeg'], + static function(FileNode $node) { + $node->mimeType() + ->allow('text/plain') + ->allow('application/json') + ->allow('image/jpeg'); + }, + ]; + + yield 'mime-type: via top-level type method' => [ + ['mimetypes:text/plain,application/json,application/pdf,image/jpeg,image/png,video/webm,video/mp4'], + static function(FileNode $node) { + $node->mimeType() + ->text('plain') + ->application('json', 'pdf') + ->image('jpeg', 'png') + ->video('webm', 'mp4'); + }, + ]; + + yield 'mime-type: de-duped' => [ + ['mimetypes:text/plain,application/json,application/pdf'], + static function(FileNode $node) { + $node->mimeType() + ->text('plain') + ->application('json', 'pdf') + ->application('pdf', 'json') + ->allow('application/json') + ->allow('application/pdf'); + }, + ]; + } +} \ No newline at end of file