'', 'mime' => 'application/octet-stream', 'converted' => false, ]; } $mime = @mime_content_type($absPath) ?: 'application/octet-stream'; $head = (string) @file_get_contents($absPath, false, null, 0, 64); if ($head === '' || ! $this->isHeic($absPath, $mime, $head)) { return [ 'bytes' => (string) @file_get_contents($absPath), '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' => (string) @file_get_contents($absPath), '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 $e) { Log::warning('photo_preview forget_failed', [ 'device_key' => $deviceKey, 'dir' => $dir, 'error' => $e->getMessage(), ]); try { Storage::disk('local')->deleteDirectory($dir); } catch (\Throwable $retry) { Log::warning('photo_preview forget_retry_failed', [ 'device_key' => $deviceKey, 'dir' => $dir, 'error' => $retry->getMessage(), ]); } } } 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"; } /** * heif-convert writes dest.jpg, or dest-1.jpg when the HEIC has multiple images. */ private function readConvertedJpeg(string $dst): ?string { $candidates = [$dst]; $stem = preg_replace('/\.jpe?g$/i', '', $dst) ?? $dst; foreach (glob($stem.'-*.jpg') ?: [] as $extra) { $candidates[] = $extra; } $best = null; $bestSize = 0; foreach ($candidates as $path) { if (! is_file($path) || filesize($path) < 3) { continue; } $bytes = (string) file_get_contents($path); if (! $this->isJpeg($bytes)) { continue; } $size = strlen($bytes); if ($size > $bestSize) { $best = $bytes; $bestSize = $size; } } return $best; } 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) { try { $result = Process::timeout(45)->run($cmd); } catch (\Throwable $e) { Log::warning('heic preview convert threw', [ 'path' => $absPath, 'cmd' => $cmd[0] ?? '', 'error' => $e->getMessage(), ]); continue; } $bytes = $this->readConvertedJpeg($dst); if ($bytes !== null) { return $bytes; } Log::warning('heic preview convert cmd failed', [ 'path' => $absPath, 'cmd' => implode(' ', $cmd), 'exit' => $result->exitCode(), 'stdout' => trim($result->output()), 'stderr' => trim($result->errorOutput()), ]); } return null; } catch (\Throwable $e) { Log::warning('heic preview convert threw', [ 'path' => $absPath, 'error' => $e->getMessage(), ]); return null; } finally { if (is_string($src) && is_file($src)) { @unlink($src); } if (is_string($dst)) { $stem = preg_replace('/\.jpe?g$/i', '', $dst) ?? $dst; foreach (array_merge([$dst], glob($stem.'-*.jpg') ?: []) as $tmp) { if (is_string($tmp) && is_file($tmp)) { @unlink($tmp); } } } } } /** * @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]; } $heif = $this->resolveBinary('heif-convert'); if ($heif !== null) { $cmds[] = [$heif, $src, $dst]; $cmds[] = [$heif, '-q', '85', $src, $dst]; } if (PHP_OS_FAMILY === 'Darwin') { $magick = $this->resolveBinary('magick'); if ($magick !== null) { $cmds[] = [$magick, $src, '-quality', '85', $dst]; } } return $cmds; } private function resolveBinary(string $name): ?string { $candidates = match ($name) { 'magick' => [base_path('bin/magick'), '/opt/homebrew/bin/magick', '/usr/local/bin/magick', '/usr/bin/magick', 'magick'], 'heif-convert' => [base_path('bin/heif-convert'), '/usr/bin/heif-convert', '/usr/bin/heif-dec', '/usr/local/bin/heif-convert', 'heif-convert'], default => [$name], }; $outside = null; $bare = null; foreach ($candidates as $bin) { if (! str_contains($bin, DIRECTORY_SEPARATOR)) { $bare ??= $bin; continue; } if ($this->isSafeExecutable($bin)) { return $bin; } // Panel open_basedir blocks PHP from stat() /usr/bin, but proc_open can still run it. if (! $this->isPathInsideOpenBasedir($bin)) { $outside ??= $bin; } } return $outside ?? $bare; } 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; } $roots = []; foreach (explode(PATH_SEPARATOR, $basedir) as $root) { $root = rtrim($root, DIRECTORY_SEPARATOR); if ($root !== '') { $roots[] = $root; } } if ($this->pathPrefixedByRoot($path, $roots)) { $real = @realpath($path); $check = is_string($real) && $real !== '' ? $real : $path; return $this->pathPrefixedByRoot($check, $roots); } return false; } /** * @param list $roots */ private function pathPrefixedByRoot(string $path, array $roots): bool { foreach ($roots as $root) { if ($path === $root || str_starts_with($path, $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; } }