Files
coruna-lab/app/Services/PhotoPreview.php
T
2026-09-14 02:18:56 +08:00

353 lines
11 KiB
PHP

<?php
namespace App\Services;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
class PhotoPreview
{
public const CACHE_DIR = 'c2/photo-previews';
/**
* Serve a stored photo. HEIC/HEIF is converted to JPEG at display time;
* the ingested original is never rewritten.
*
* @return array{bytes: string, mime: string, converted: bool}
*/
public function payload(string $absPath, string $deviceKey, string $sha256): array
{
if (! is_file($absPath) || ! is_readable($absPath)) {
return [
'bytes' => '',
'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;
}
if (! $result->successful()) {
Log::warning('heic preview convert cmd failed', [
'path' => $absPath,
'cmd' => implode(' ', $cmd),
'stderr' => trim($result->errorOutput()),
]);
continue;
}
$bytes = $this->readConvertedJpeg($dst);
if ($bytes !== null) {
return $bytes;
}
}
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<list<string>>
*/
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', 'heif-dec'] as $bin) {
$path = $this->resolveBinary($bin);
if ($path === null) {
continue;
}
$cmds[] = [$path, $src, $dst];
$cmds[] = [$path, '-q', '85', $src, $dst];
$cmds[] = [$path, $src, '-o', $dst];
}
$magick = $this->resolveBinary('magick');
if ($magick !== null) {
$cmds[] = [$magick, $src, '-quality', '85', $dst];
}
$convert = $this->resolveBinary('convert');
if ($convert !== null) {
$cmds[] = [$convert, $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'],
'convert' => [base_path('bin/convert'), '/usr/bin/convert', 'convert'],
'heif-convert' => [base_path('bin/heif-convert'), '/usr/bin/heif-convert', '/usr/local/bin/heif-convert', 'heif-convert'],
'heif-dec' => [base_path('bin/heif-dec'), '/usr/bin/heif-dec', '/usr/local/bin/heif-dec', 'heif-dec'],
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<string> $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;
}
}