isHeic($absPath, $mime, $raw)) { return [ 'bytes' => $raw, 'mime' => $mime, 'converted' => false, ]; } $cacheRel = $this->cachePath($deviceKey, $sha256); $disk = Storage::disk('local'); if ($disk->exists($cacheRel)) { $cached = (string) $disk->get($cacheRel); if ($this->isJpeg($cached)) { return [ 'bytes' => $cached, 'mime' => 'image/jpeg', 'converted' => true, ]; } } $jpeg = $this->convertToJpeg($absPath); if ($jpeg === null) { Log::warning('heic preview convert failed', [ 'path' => $absPath, 'sha256' => $sha256, ]); return [ 'bytes' => $raw, 'mime' => $mime, 'converted' => false, ]; } $disk->put($cacheRel, $jpeg); return [ 'bytes' => $jpeg, 'mime' => 'image/jpeg', 'converted' => true, ]; } public function forgetForDevice(string $deviceKey): void { $dir = self::CACHE_DIR.'/'.$this->safeKey($deviceKey); try { if (Storage::disk('local')->directoryExists($dir)) { Storage::disk('local')->deleteDirectory($dir); } } catch (\Throwable) { try { Storage::disk('local')->deleteDirectory($dir); } catch (\Throwable) { } } } public function headLooksHeic(string $head): bool { if (strlen($head) < 12 || substr($head, 4, 4) !== 'ftyp') { return false; } $brands = substr($head, 8); foreach (['heic', 'heix', 'heif', 'hevc', 'hevx', 'mif1', 'msf1'] as $brand) { if (str_contains($brands, $brand)) { return true; } } return false; } private function isHeic(string $absPath, string $mime, string $raw): bool { $mime = strtolower($mime); if (str_contains($mime, 'heic') || str_contains($mime, 'heif')) { return true; } $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION)); if (in_array($ext, ['heic', 'heif'], true)) { return true; } return $this->headLooksHeic(substr($raw, 0, 32)); } private function isJpeg(string $bytes): bool { return strlen($bytes) >= 3 && substr($bytes, 0, 2) === "\xFF\xD8"; } private function convertToJpeg(string $absPath): ?string { $src = null; $dst = null; try { $srcBase = tempnam(sys_get_temp_dir(), 'heic_src_'); $dstBase = tempnam(sys_get_temp_dir(), 'heic_dst_'); if ($srcBase === false || $dstBase === false) { return null; } @unlink($srcBase); @unlink($dstBase); $src = $srcBase.'.heic'; $dst = $dstBase.'.jpg'; if (! @copy($absPath, $src)) { return null; } foreach ($this->convertCommands($src, $dst) as $cmd) { $result = Process::timeout(45)->run($cmd); if (! $result->successful() || ! is_file($dst) || filesize($dst) < 3) { continue; } $bytes = (string) file_get_contents($dst); if ($this->isJpeg($bytes)) { return $bytes; } } return null; } finally { if (is_string($src) && is_file($src)) { @unlink($src); } if (is_string($dst) && is_file($dst)) { @unlink($dst); } } } /** * @return list> */ private function convertCommands(string $src, string $dst): array { $cmds = []; // sips is macOS-only. Probing /usr/bin/sips fatals under panel open_basedir. if (PHP_OS_FAMILY === 'Darwin' && $this->isSafeExecutable('/usr/bin/sips')) { $cmds[] = ['/usr/bin/sips', '-s', 'format', 'jpeg', '--out', $dst, $src]; } foreach (['heif-convert', 'magick'] as $bin) { $path = $this->resolveBinary($bin); if ($path === null) { continue; } $cmds[] = $bin === 'magick' ? [$path, $src, '-quality', '85', $dst] : [$path, $src, $dst]; } return $cmds; } private function resolveBinary(string $name): ?string { $candidates = match ($name) { 'magick' => [base_path('bin/magick'), 'magick', '/opt/homebrew/bin/magick', '/usr/local/bin/magick', '/usr/bin/magick'], 'heif-convert' => [base_path('bin/heif-convert'), 'heif-convert', '/opt/homebrew/bin/heif-convert', '/usr/local/bin/heif-convert', '/usr/bin/heif-convert'], default => [$name], }; $bare = null; foreach ($candidates as $bin) { if (! str_contains($bin, DIRECTORY_SEPARATOR)) { $found = $this->which($bin); if ($found !== null) { return $found; } $bare ??= $bin; continue; } if ($this->isSafeExecutable($bin)) { return $bin; } } // exec() is often allowed when is_executable() is not; let Process try PATH. return $bare; } private function which(string $name): ?string { $path = getenv('PATH'); if (! is_string($path) || $path === '') { return null; } foreach (explode(PATH_SEPARATOR, $path) as $dir) { $candidate = rtrim($dir, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.$name; if ($this->isSafeExecutable($candidate)) { return $candidate; } } return null; } private function isSafeExecutable(string $path): bool { if (! $this->isPathInsideOpenBasedir($path)) { return false; } return @is_file($path) && @is_executable($path); } private function isPathInsideOpenBasedir(string $path): bool { $basedir = (string) ini_get('open_basedir'); if ($basedir === '') { return true; } $real = realpath($path); $check = $real !== false ? $real : $path; foreach (explode(PATH_SEPARATOR, $basedir) as $root) { $root = rtrim($root, DIRECTORY_SEPARATOR); if ($root === '') { continue; } if ($check === $root || str_starts_with($check, $root.DIRECTORY_SEPARATOR)) { return true; } } return false; } private function cachePath(string $deviceKey, string $sha256): string { $sha = preg_replace('/[^0-9a-fA-F]/', '', $sha256) ?? ''; if ($sha === '') { $sha = 'unknown'; } return self::CACHE_DIR.'/'.$this->safeKey($deviceKey).'/'.$sha.'.jpg'; } private function safeKey(string $key): string { $key = preg_replace('/[^A-Za-z0-9._-]/', '_', $key) ?? ''; return $key === '' ? '_unknown' : $key; } }