Resolve local and object-storage originals, persist download status, and audit missing files without guessing from a single path.
208 lines
7.7 KiB
PHP
208 lines
7.7 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Artwork;
|
|
use App\Services\Uploads\UploadStorageService;
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
final class ArtworkOriginalFileLocator
|
|
{
|
|
/** @var list<string> */
|
|
private const ALLOWED_EXTENSIONS = [
|
|
'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'tiff',
|
|
'zip', 'rar', '7z', 'tar', 'gz',
|
|
];
|
|
|
|
public function __construct(
|
|
private readonly UploadStorageService $storage,
|
|
) {}
|
|
|
|
public function resolveLocalPath(Artwork $artwork): string
|
|
{
|
|
$objectPath = $this->resolveObjectPath($artwork);
|
|
$prefix = $this->originalObjectPrefix();
|
|
|
|
if ($objectPath !== '' && str_starts_with($objectPath, $prefix)) {
|
|
$suffix = substr($objectPath, strlen($prefix));
|
|
$root = rtrim($this->storage->localOriginalsRoot(), DIRECTORY_SEPARATOR);
|
|
|
|
return $root . DIRECTORY_SEPARATOR . str_replace(['/', '\\'], DIRECTORY_SEPARATOR, (string) $suffix);
|
|
}
|
|
|
|
$hash = strtolower((string) $artwork->hash);
|
|
$ext = $this->safeExtension($artwork);
|
|
|
|
if (! $this->isValidHash($hash) || $ext === '') {
|
|
return '';
|
|
}
|
|
|
|
$root = rtrim($this->storage->localOriginalsRoot(), DIRECTORY_SEPARATOR);
|
|
|
|
return $root
|
|
. DIRECTORY_SEPARATOR . substr($hash, 0, 2)
|
|
. DIRECTORY_SEPARATOR . substr($hash, 2, 2)
|
|
. DIRECTORY_SEPARATOR . $hash . '.' . $ext;
|
|
}
|
|
|
|
/** @return array{status:string,source:?string,disk:?string,path:string,object_key:string,exists:bool,size:?int,file_name:string,file_ext:string} */
|
|
public function resolve(Artwork $artwork): array
|
|
{
|
|
$fileName = trim((string) ($artwork->file_name ?? ''));
|
|
$extension = $this->safeExtension($artwork);
|
|
$localPath = $this->resolveLocalPath($artwork);
|
|
|
|
if ($localPath !== '' && is_file($localPath)) {
|
|
return $this->result('available', 'local', null, $localPath, '', true, (int) filesize($localPath), $fileName, $extension);
|
|
}
|
|
|
|
if ($this->isTruePending($artwork)) {
|
|
return $this->result('pending', null, null, $localPath, '', false, null, $fileName, $extension);
|
|
}
|
|
|
|
$objectKey = $this->resolveObjectPath($artwork);
|
|
if ($objectKey !== '') {
|
|
try {
|
|
$diskName = $this->storage->objectDiskName();
|
|
$disk = Storage::disk($diskName);
|
|
if ($disk->exists($objectKey)) {
|
|
return $this->result('available', 'object', $diskName, '', $objectKey, true, (int) $disk->size($objectKey), $fileName, $extension);
|
|
}
|
|
} catch (\Throwable) {
|
|
// Treat unavailable storage as unresolved; the route logs context.
|
|
}
|
|
}
|
|
|
|
$legacyPath = $this->resolveReadonlyLegacyPath($artwork);
|
|
if ($legacyPath !== '' && is_file($legacyPath)) {
|
|
return $this->result('available', 'legacy', null, $legacyPath, '', true, (int) filesize($legacyPath), $fileName, $extension);
|
|
}
|
|
|
|
if ($this->hasIncompleteLegacyMetadata($artwork)) {
|
|
return $this->result('unknown', null, null, $localPath, $objectKey, false, null, $fileName, $extension);
|
|
}
|
|
|
|
return $this->result('missing', null, null, $localPath, $objectKey, false, null, $fileName, $extension);
|
|
}
|
|
|
|
private function isTruePending(Artwork $artwork): bool
|
|
{
|
|
return strtolower(trim((string) ($artwork->file_name ?? ''))) === 'pending'
|
|
&& trim((string) ($artwork->hash ?? '')) === ''
|
|
&& trim((string) ($artwork->file_ext ?? '')) === ''
|
|
&& trim((string) ($artwork->file_path ?? '')) === '';
|
|
}
|
|
|
|
private function hasIncompleteLegacyMetadata(Artwork $artwork): bool
|
|
{
|
|
return ! $this->isValidHash(strtolower((string) ($artwork->hash ?? '')))
|
|
|| $this->safeExtension($artwork) === '';
|
|
}
|
|
|
|
private function resolveReadonlyLegacyPath(Artwork $artwork): string
|
|
{
|
|
$legacyRoot = rtrim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, trim((string) config('uploads.legacy_originals_root', ''))), DIRECTORY_SEPARATOR);
|
|
$legacyRelative = $this->legacyRelativePath($artwork);
|
|
if ($legacyRoot !== '' && $legacyRelative !== '') {
|
|
return $legacyRoot . DIRECTORY_SEPARATOR . str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $legacyRelative);
|
|
}
|
|
|
|
$root = rtrim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, trim((string) config('uploads.readonly_backup_originals_root', ''))), DIRECTORY_SEPARATOR);
|
|
$hash = strtolower((string) $artwork->hash);
|
|
$extension = $this->safeExtension($artwork);
|
|
if ($root === '' || ! $this->isValidHash($hash) || $extension === '') {
|
|
return '';
|
|
}
|
|
|
|
return $root . DIRECTORY_SEPARATOR . substr($hash, 0, 2) . DIRECTORY_SEPARATOR . substr($hash, 2, 2) . DIRECTORY_SEPARATOR . $hash . '.' . $extension;
|
|
}
|
|
|
|
private function legacyRelativePath(Artwork $artwork): string
|
|
{
|
|
$path = trim((string) ($artwork->file_path ?? ''), '/\\');
|
|
if ($path === '' || ! str_starts_with(strtolower($path), 'legacy/uploads/')) {
|
|
return '';
|
|
}
|
|
|
|
$relative = substr($path, strlen('legacy/uploads/'));
|
|
if ($relative === false || $relative === '' || str_contains($relative, '..')) {
|
|
return '';
|
|
}
|
|
|
|
return 'legacy/uploads/' . ltrim($relative, '/\\');
|
|
}
|
|
|
|
private function safeExtension(Artwork $artwork): string
|
|
{
|
|
$stored = strtolower(ltrim(trim((string) ($artwork->file_ext ?? '')), '.'));
|
|
if (in_array($stored, self::ALLOWED_EXTENSIONS, true)) {
|
|
return $stored;
|
|
}
|
|
|
|
foreach ([(string) ($artwork->file_name ?? ''), (string) ($artwork->file_path ?? '')] as $candidate) {
|
|
$inferred = strtolower(ltrim((string) pathinfo($candidate, PATHINFO_EXTENSION), '.'));
|
|
if (in_array($inferred, self::ALLOWED_EXTENSIONS, true)) {
|
|
return $inferred;
|
|
}
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
private function result(string $status, ?string $source, ?string $disk, string $path, string $objectKey, bool $exists, ?int $size, string $fileName, string $extension): array
|
|
{
|
|
return [
|
|
'status' => $status,
|
|
'source' => $source,
|
|
'disk' => $disk,
|
|
'path' => $path,
|
|
'object_key' => $objectKey,
|
|
'exists' => $exists,
|
|
'size' => $size,
|
|
'file_name' => $fileName,
|
|
'file_ext' => $extension,
|
|
];
|
|
}
|
|
|
|
public function resolveObjectPath(Artwork $artwork): string
|
|
{
|
|
$relative = trim((string) $artwork->file_path, '/');
|
|
$prefix = $this->originalObjectPrefix();
|
|
|
|
if ($relative !== '' && (str_starts_with($relative, $prefix) || str_starts_with(strtolower($relative), 'legacy/uploads/'))) {
|
|
return $relative;
|
|
}
|
|
|
|
$hash = strtolower((string) $artwork->hash);
|
|
$ext = $this->safeExtension($artwork);
|
|
|
|
if (! $this->isValidHash($hash) || $ext === '') {
|
|
return '';
|
|
}
|
|
|
|
return $this->storage->objectPathForVariant('original', $hash, $hash . '.' . $ext);
|
|
}
|
|
|
|
public function resolveObjectUrl(Artwork $artwork): ?string
|
|
{
|
|
$objectPath = $this->resolveObjectPath($artwork);
|
|
if ($objectPath === '') {
|
|
return null;
|
|
}
|
|
|
|
return Storage::disk($this->storage->objectDiskName())->url($objectPath);
|
|
}
|
|
|
|
private function originalObjectPrefix(): string
|
|
{
|
|
return trim($this->storage->objectBasePrefix(), '/') . '/original/';
|
|
}
|
|
|
|
private function isValidHash(string $hash): bool
|
|
{
|
|
return preg_match('/^[a-f0-9]{40}$/', $hash) === 1;
|
|
}
|
|
}
|