diff --git a/src/Application/ImageValidator.php b/src/Application/ImageValidator.php new file mode 100644 index 0000000..ee15c84 --- /dev/null +++ b/src/Application/ImageValidator.php @@ -0,0 +1,81 @@ + $maxBytes) { + throw new InvalidImageException("File exceeds the {$maxBytes} byte limit."); + } + + $handle = fopen($tempPath, 'rb'); + if ($handle === false) { + throw new InvalidImageException('Could not read upload.'); + } + $header = fread($handle, 12); + fclose($handle); + if ($header === false || strlen($header) < 12) { + throw new InvalidImageException('Upload is too small to be an image.'); + } + + $detected = self::detect($header); + if ($detected === null) { + throw new InvalidImageException('File is not a supported image type.'); + } + + [$canonical, $extension] = $detected; + + if ($declaredMime !== $canonical) { + throw new InvalidImageException('Declared MIME does not match file contents.'); + } + + $info = getimagesize($tempPath); + if ($info === false) { + throw new InvalidImageException('Image is corrupt or unreadable.'); + } + + [$width, $height] = $info; + if ($width > $maxDimension || $height > $maxDimension) { + throw new InvalidImageException("Image dimensions exceed the {$maxDimension} pixel limit."); + } + + return new ValidatedImage($canonical, $extension); + } +} diff --git a/src/Application/InvalidImageException.php b/src/Application/InvalidImageException.php new file mode 100644 index 0000000..5880d6b --- /dev/null +++ b/src/Application/InvalidImageException.php @@ -0,0 +1,11 @@ +canonicalMime); + self::assertSame('png', $result->extension); + } finally { + unlink($path); + } + } + + public function testItRejectsAFileWithTheWrongDeclaredMime(): void + { + $path = self::TMP_DIR . '/bf-bad-mime-' . bin2hex(random_bytes(4)) . '.png'; + self::assertNotFalse(file_put_contents($path, self::validPng(8, 8))); + + try { + $this->expectException(InvalidImageException::class); + $this->expectExceptionMessage('Declared MIME does not match file contents'); + ImageValidator::validate($path, 'image/jpeg'); + } finally { + unlink($path); + } + } + + public function testItRejectsTextContent(): void + { + $path = self::TMP_DIR . '/bf-text-' . bin2hex(random_bytes(4)) . '.txt'; + self::assertNotFalse(file_put_contents($path, 'this is not an image')); + + try { + $this->expectException(InvalidImageException::class); + ImageValidator::validate($path, 'image/png'); + } finally { + unlink($path); + } + } + + public function testItRejectsOversizedFiles(): void + { + $path = self::TMP_DIR . '/bf-huge-' . bin2hex(random_bytes(4)) . '.png'; + // Create a 2 MB + 1 byte file + $bytes = str_repeat('A', 2_000_001); + self::assertNotFalse(file_put_contents($path, $bytes)); + + try { + $this->expectException(InvalidImageException::class); + $this->expectExceptionMessage('exceeds the 2000000 byte limit'); + ImageValidator::validate($path, 'image/png'); + } finally { + unlink($path); + } + } + + public function testItRejectsOversizedDimensions(): void + { + $path = self::TMP_DIR . '/bf-wide-' . bin2hex(random_bytes(4)) . '.png'; + self::assertNotFalse(file_put_contents($path, self::validPng(600, 8))); + + try { + $this->expectException(InvalidImageException::class); + $this->expectExceptionMessage('exceed the 512 pixel limit'); + ImageValidator::validate($path, 'image/png'); + } finally { + unlink($path); + } + } + + public function testItAcceptsJpegWebpAndGif(): void + { + $jpeg = self::TMP_DIR . '/bf-jpeg-' . bin2hex(random_bytes(4)) . '.jpg'; + $webp = self::TMP_DIR . '/bf-webp-' . bin2hex(random_bytes(4)) . '.webp'; + $gif = self::TMP_DIR . '/bf-gif-' . bin2hex(random_bytes(4)) . '.gif'; + file_put_contents($jpeg, self::validJpeg(8, 8)); + file_put_contents($webp, self::validWebp(8, 8)); + file_put_contents($gif, self::validGif(8, 8)); + + try { + self::assertSame('jpg', ImageValidator::validate($jpeg, 'image/jpeg')->extension); + self::assertSame('webp', ImageValidator::validate($webp, 'image/webp')->extension); + self::assertSame('gif', ImageValidator::validate($gif, 'image/gif')->extension); + } finally { + unlink($jpeg); + unlink($webp); + unlink($gif); + } + } + + /** + * Build a minimal valid PNG of the given dimensions, using a pre-baked + * 8x8 transparent PNG as the base and trusting `getimagesize` to accept + * any correctly-formed PNG. We just need the file to (1) have the PNG + * magic and (2) be decodable by GD or `getimagesize`. + * + * For the oversized-dimensions test we synthesize a 600x8 PNG via + * `imagecreatetruecolor` + `imagepng` to force the IHDR to claim those + * dimensions. + */ + private static function validPng(int $width, int $height): string + { + if ($width === 8 && $height === 8) { + return base64_decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAFElEQVR4nGNgYGD4z0AswK' . + 'EWBgYGRgYGBkYGRgAAB4nCH2AAAAAElFTkSuQmCC', + true, + ); + } + + $im = imagecreatetruecolor($width, $height); + if ($im === false) { + throw new \RuntimeException('Failed to create image.'); + } + ob_start(); + imagepng($im); + $bytes = ob_get_clean(); + imagedestroy($im); + + return (string) $bytes; + } + + private static function validJpeg(int $width, int $height): string + { + $im = imagecreatetruecolor($width, $height); + if ($im === false) { + throw new \RuntimeException('Failed to create image.'); + } + ob_start(); + imagejpeg($im); + $bytes = ob_get_clean(); + imagedestroy($im); + + return (string) $bytes; + } + + private static function validWebp(int $width, int $height): string + { + $im = imagecreatetruecolor($width, $height); + if ($im === false) { + throw new \RuntimeException('Failed to create image.'); + } + ob_start(); + imagewebp($im); + $bytes = ob_get_clean(); + imagedestroy($im); + + return (string) $bytes; + } + + private static function validGif(int $width, int $height): string + { + $im = imagecreatetruecolor($width, $height); + if ($im === false) { + throw new \RuntimeException('Failed to create image.'); + } + ob_start(); + imagegif($im); + $bytes = ob_get_clean(); + imagedestroy($im); + + return (string) $bytes; + } +}