279 lines
8.4 KiB
PHP
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;
|
|
}
|
|
}
|