344 lines
11 KiB
PHP
344 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;
|
|
}
|
|
$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<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];
|
|
}
|
|
$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<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;
|
|
}
|
|
}
|