Files
coruna-lab/app/Services/PhotoPreview.php
T
2026-09-01 05:52:29 +08:00

279 lines
8.4 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
{
$mime = @mime_content_type($absPath) ?: 'application/octet-stream';
$raw = (string) file_get_contents($absPath);
if ($raw === '' || ! $this->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 $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";
}
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<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', '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;
}
}