feat: add image content validator
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Application;
|
||||
|
||||
final class ImageValidator
|
||||
{
|
||||
/**
|
||||
* @return array{string, string} [canonicalMime, extension] for a recognized image, or null.
|
||||
*/
|
||||
private static function detect(string $header): ?array
|
||||
{
|
||||
if (substr($header, 0, 8) === "\x89PNG\r\n\x1a\n") {
|
||||
return ['image/png', 'png'];
|
||||
}
|
||||
|
||||
if (substr($header, 0, 3) === "\xff\xd8\xff") {
|
||||
return ['image/jpeg', 'jpg'];
|
||||
}
|
||||
|
||||
if (substr($header, 0, 6) === 'GIF87a' || substr($header, 0, 6) === 'GIF89a') {
|
||||
return ['image/gif', 'gif'];
|
||||
}
|
||||
|
||||
if (substr($header, 0, 4) === 'RIFF' && substr($header, 8, 4) === 'WEBP') {
|
||||
return ['image/webp', 'webp'];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function validate(
|
||||
string $tempPath,
|
||||
string $declaredMime,
|
||||
int $maxBytes = 2_000_000,
|
||||
int $maxDimension = 512,
|
||||
): ValidatedImage {
|
||||
if (!is_file($tempPath)) {
|
||||
throw new InvalidImageException('Upload is not a file.');
|
||||
}
|
||||
|
||||
$size = filesize($tempPath);
|
||||
if ($size === false || $size > $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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Application;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class InvalidImageException extends RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Application;
|
||||
|
||||
final readonly class ValidatedImage
|
||||
{
|
||||
public function __construct(
|
||||
public string $canonicalMime,
|
||||
public string $extension,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Tests\Unit\Application;
|
||||
|
||||
use BattleForge\Application\ImageValidator;
|
||||
use BattleForge\Application\InvalidImageException;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ImageValidatorTest extends TestCase
|
||||
{
|
||||
private const TMP_DIR = '/tmp';
|
||||
|
||||
public function testItAcceptsAValidPng(): void
|
||||
{
|
||||
$path = self::TMP_DIR . '/bf-valid-' . bin2hex(random_bytes(4)) . '.png';
|
||||
self::assertNotFalse(file_put_contents($path, self::validPng(8, 8)));
|
||||
|
||||
try {
|
||||
$result = ImageValidator::validate($path, 'image/png');
|
||||
self::assertSame('image/png', $result->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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user