'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 function __construct(private readonly string $dir) { if (!is_dir($this->dir) && !mkdir($this->dir, 0775, true) && !is_dir($this->dir)) { throw new RuntimeException("Cannot create texture directory {$this->dir}"); } } /** A stored file name, as it appears in a project document. */ 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. * * @return array{file:string,width:int,height:int,bytes:int} * @throws InvalidImageException when the bytes are not a supported image */ public function store(string $bytes): 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'); } $file = hash('sha256', $bytes) . '.' . $extension; $path = $this->dir . '/' . $file; if (!is_file($path)) { $this->writeFile($path, $bytes); } return ['file' => $file, 'width' => $width, 'height' => $height, 'bytes' => strlen($bytes)]; } /** * Every stored file, newest first – what the image picker offers. * * The pixel size is 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 list */ public function list(): array { $items = []; foreach (glob($this->dir . '/*.*') ?: [] as $path) { $file = basename($path); if (!self::isValidName($file)) { continue; } $size = @getimagesize($path); $items[] = [ 'file' => $file, 'width' => (int) ($size[0] ?? 0), 'height' => (int) ($size[1] ?? 0), 'bytes' => (int) filesize($path), 'updatedAt' => gmdate('c', (int) filemtime($path)), ]; } usort($items, static fn(array $a, array $b): int => strcmp($b['updatedAt'], $a['updatedAt'])); return $items; } public function delete(string $file): bool { if (!self::isValidName($file)) { return false; } $path = $this->dir . '/' . $file; return is_file($path) && unlink($path); } /** * Writes via a temp file in the same directory, so a reader never sees a half-written * image: the name only ever appears once the bytes behind it are complete. */ private function writeFile(string $path, string $bytes): void { $tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp'; if (file_put_contents($tmp, $bytes, LOCK_EX) === false || !rename($tmp, $path)) { @unlink($tmp); throw new RuntimeException('Cannot write texture file'); } @chmod($path, 0664); } } /** Thrown by TextureStore::store when the uploaded bytes are not a usable image. */ final class InvalidImageException extends RuntimeException { }