Files
SkinbaseNova/app/Services/Uploads/UploadDerivativesService.php

290 lines
10 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Uploads;
use App\Services\Images\SquareThumbnailService;
use Illuminate\Support\Facades\File;
use Intervention\Image\ImageManager as ImageManager;
use Intervention\Image\Interfaces\ImageInterface as InterventionImageInterface;
use RuntimeException;
final class UploadDerivativesService
{
/**
* @var list<string>
*/
private const PASSTHROUGH_DOWNLOAD_EXTENSIONS = [
'jpg',
'jpeg',
'png',
'gif',
'webp',
'bmp',
'tif',
'tiff',
'svg',
'avif',
'heic',
'heif',
'ico',
'jfif',
'zip',
'rar',
'7z',
'7zip',
'tar',
'gz',
'tgz',
'bz2',
'xz',
];
private bool $imageAvailable = false;
private ?ImageManager $manager = null;
public function __construct(
private readonly UploadStorageService $storage,
private readonly SquareThumbnailService $squareThumbnails,
)
{
// Intervention Image v3 uses ImageManager; instantiate appropriate driver
try {
$this->manager = extension_loaded('gd') ? ImageManager::gd() : ImageManager::imagick();
$this->imageAvailable = true;
} catch (\Throwable $e) {
logger()->warning('Intervention Image present but configuration failed: ' . $e->getMessage());
$this->imageAvailable = false;
$this->manager = null;
}
}
/**
* @return array{local_path: string, object_path: string, filename: string, mime: string, size: int, ext: string}
*/
public function storeOriginal(string $sourcePath, string $hash, ?string $originalFileName = null): array
{
$origExt = $this->resolveOriginalExtension($sourcePath, $originalFileName);
$filename = $hash . ($origExt !== '' ? '.' . $origExt : '');
$target = $this->storage->localOriginalPath($hash, $filename);
if (! File::copy($sourcePath, $target)) {
throw new RuntimeException('Unable to copy original file into local artwork originals storage.');
}
$mime = (string) (File::mimeType($target) ?: 'application/octet-stream');
$size = (int) filesize($target);
$objectPath = $this->storage->objectPathForVariant('original', $hash, $filename);
$this->storage->putObjectFromPath($target, $objectPath, $mime);
return [
'local_path' => $target,
'object_path' => $objectPath,
'filename' => $filename,
'mime' => $mime,
'size' => $size,
'ext' => $origExt,
];
}
/**
* @return array{local_path: string, object_path: string, filename: string, mime: string, size: int, ext: string}
*/
public function storeDownloadOriginal(string $sourcePath, string $hash, ?string $originalFileName = null): array
{
$origExt = $this->resolveOriginalExtension($sourcePath, $originalFileName);
if (in_array($origExt, self::PASSTHROUGH_DOWNLOAD_EXTENSIONS, true)) {
return $this->storeOriginal($sourcePath, $hash, $originalFileName);
}
$filename = $hash . '.zip';
$target = $this->storage->localOriginalPath($hash, $filename);
File::delete($target);
$zip = new \ZipArchive();
$opened = $zip->open($target, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
if ($opened !== true) {
throw new RuntimeException('Unable to create zip archive for download original.');
}
try {
if (! $zip->addFile($sourcePath, $this->resolveDownloadArchiveEntryName($originalFileName, $hash, $origExt))) {
throw new RuntimeException('Unable to add source file to download archive.');
}
} finally {
$zip->close();
}
$size = (int) (filesize($target) ?: 0);
$objectPath = $this->storage->objectPathForVariant('original', $hash, $filename);
$this->storage->putObjectFromPath($target, $objectPath, 'application/zip');
return [
'local_path' => $target,
'object_path' => $objectPath,
'filename' => $filename,
'mime' => 'application/zip',
'size' => $size,
'ext' => 'zip',
];
}
private function resolveOriginalExtension(string $sourcePath, ?string $originalFileName): string
{
$fromClientName = strtolower((string) pathinfo((string) $originalFileName, PATHINFO_EXTENSION));
if ($fromClientName !== '' && preg_match('/^[a-z0-9]{1,12}$/', $fromClientName) === 1) {
return $fromClientName;
}
$fromSource = strtolower((string) pathinfo($sourcePath, PATHINFO_EXTENSION));
if ($fromSource !== '' && $fromSource !== 'upload' && preg_match('/^[a-z0-9]{1,12}$/', $fromSource) === 1) {
return $fromSource;
}
$mime = File::exists($sourcePath) ? (string) (File::mimeType($sourcePath) ?? '') : '';
return $this->extensionFromMime($mime);
}
private function resolveDownloadArchiveEntryName(?string $originalFileName, string $hash, string $sourceExt): string
{
$candidate = trim((string) pathinfo((string) $originalFileName, PATHINFO_FILENAME));
$candidate = str_replace(['/', '\\'], '-', $candidate);
$candidate = trim((string) preg_replace('/[\x00-\x1F\x7F]/', '', $candidate));
$candidate = trim($candidate, ". \t\n\r\0\x0B");
if ($candidate === '' || $candidate === '.' || $candidate === '..') {
$candidate = $hash !== '' ? $hash : 'artwork';
}
return $candidate . '.' . ($sourceExt !== '' ? $sourceExt : 'bin');
}
private function extensionFromMime(string $mime): string
{
return match (strtolower($mime)) {
'image/jpeg', 'image/jpg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
'image/gif' => 'gif',
'image/bmp' => 'bmp',
'image/tiff' => 'tif',
'application/zip', 'application/x-zip-compressed' => 'zip',
'application/x-rar-compressed', 'application/vnd.rar' => 'rar',
'application/x-7z-compressed' => '7z',
'application/x-tar' => 'tar',
'application/gzip', 'application/x-gzip' => 'gz',
default => 'bin',
};
}
/**
* @return array<string, array{path: string, size: int, mime: string}>
*/
public function generatePublicDerivatives(string $sourcePath, string $hash): array
{
return $this->generateSelectedPublicDerivatives(
$sourcePath,
$hash,
array_keys((array) config('uploads.derivatives', [])),
);
}
/**
* @param list<string> $variants
* @return array<string, array{path: string, size: int, mime: string}>
*/
public function generateSelectedPublicDerivatives(string $sourcePath, string $hash, array $variants): array
{
$this->assertImageAvailable();
$quality = (int) config('uploads.quality', 85);
$configuredVariants = (array) config('uploads.derivatives', []);
$written = [];
foreach ($variants as $variant) {
$variant = strtolower(trim((string) $variant));
if ($variant === '' || ! array_key_exists($variant, $configuredVariants)) {
continue;
}
$options = (array) $configuredVariants[$variant];
if ($variant === 'sq') {
$written[$variant] = $this->generateSquareDerivative($sourcePath, $hash);
continue;
}
$filename = $hash . '.webp';
$objectPath = $this->storage->objectPathForVariant($variant, $hash, $filename);
/** @var InterventionImageInterface $img */
$img = $this->manager->read($sourcePath);
if (isset($options['size'])) {
$size = (int) $options['size'];
$out = $img->cover($size, $size);
} else {
$max = (int) ($options['max'] ?? 0);
if ($max <= 0) {
$max = 2560;
}
$out = $img->scaleDown($max, $max);
}
$encoder = new \Intervention\Image\Encoders\WebpEncoder($quality);
$encoded = (string) $out->encode($encoder);
$this->storage->putObjectContents($objectPath, $encoded, 'image/webp');
$written[$variant] = [
'path' => $objectPath,
'size' => strlen($encoded),
'mime' => 'image/webp',
];
}
return $written;
}
/**
* @param array<string, mixed> $options
* @return array{path: string, size: int, mime: string, result: SquareThumbnailResultData}
*/
public function generateSquareDerivative(string $sourcePath, string $hash, array $options = []): array
{
$filename = $hash . '.webp';
$objectPath = $this->storage->objectPathForVariant('sq', $hash, $filename);
$temporaryPath = tempnam(sys_get_temp_dir(), 'sq-derivative-');
if ($temporaryPath === false) {
throw new RuntimeException('Unable to allocate a temporary file for square derivative generation.');
}
$temporaryWebp = $temporaryPath . '.webp';
rename($temporaryPath, $temporaryWebp);
try {
$result = $this->squareThumbnails->generateFromPath($sourcePath, $temporaryWebp, $options);
$mime = 'image/webp';
$size = (int) filesize($temporaryWebp);
$this->storage->putObjectFromPath($temporaryWebp, $objectPath, $mime);
return [
'path' => $objectPath,
'size' => $size,
'mime' => $mime,
'result' => $result,
];
} finally {
File::delete($temporaryWebp);
}
}
private function assertImageAvailable(): void
{
if (! $this->imageAvailable) {
throw new RuntimeException('Intervention Image is not available.');
}
}
}