'png', IMAGETYPE_JPEG => 'jpg', IMAGETYPE_WEBP => 'webp', IMAGETYPE_GIF => 'gif', ]; private const MIME_TYPES = [ 'image/png' => IMAGETYPE_PNG, 'image/jpeg' => IMAGETYPE_JPEG, 'image/webp' => IMAGETYPE_WEBP, 'image/gif' => IMAGETYPE_GIF, ]; /** * Refuses images no display can use anyway. Dimensions come from the header, so a * "decompression bomb" (tiny file, gigantic canvas) is rejected before anything * allocates memory for it. */ private const MAX_DIMENSION = 8192; public static function isValidName(string $name): bool { return (bool) preg_match('/^[a-f0-9]{64}\.(png|jpg|webp|gif)$/', $name); } /** * Stores the raw bytes of an image and returns its record. * * The extension is derived from the sniffed content, never from anything the client * says, so no request can choose the name or the type of the file it creates. * * `$originalName` is kept beside the file as a caption (see OriginalName) – the picture may be * re-encoded on the way, so it can name a `.png` that is stored as `.webp`. That is the point: * it records where the file came from, not what it now is. * * @return array{file:string,name:string,width:int,height:int,bytes:int} * @throws InvalidImageException when the bytes are not a supported image */ public function store(string $bytes, string $originalName = ''): array { $size = @getimagesizefromstring($bytes); if ($size === false) { throw new InvalidImageException('Not a readable image'); } [$width, $height, $type] = $size; $extension = self::EXTENSIONS[$type] ?? ''; if ($extension === '') { throw new InvalidImageException('Unsupported image format – use PNG, JPEG, WebP or GIF'); } // Two independent sniffs must agree; a file that reads as one type by header and as // another by content is a polyglot, and we would rather not find out what it is. $mime = (new finfo(FILEINFO_MIME_TYPE))->buffer($bytes); if ((self::MIME_TYPES[$mime] ?? 0) !== $type) { throw new InvalidImageException('Image content does not match its format'); } if ($width < 1 || $height < 1 || $width > self::MAX_DIMENSION || $height > self::MAX_DIMENSION) { throw new InvalidImageException('Image must be between 1 and ' . self::MAX_DIMENSION . ' pixels per side'); } return $this->keep($bytes, $extension, $originalName, ['width' => $width, 'height' => $height]); } /** * The pixel size, read from each header rather than left to the client: a texture asset records * the size, so a picker that only knew the file name could not create one without decoding the * picture first. An unreadable file reports 0 × 0 and the picker skips it. * * @return array{width:int,height:int} */ protected function details(string $path): array { $size = @getimagesize($path); return ['width' => (int) ($size[0] ?? 0), 'height' => (int) ($size[1] ?? 0)]; } } /** Thrown by TextureStore::store when the uploaded bytes are not a usable image. */ final class InvalidImageException extends RuntimeException { }