Wire admin studio SSR and search infrastructure
This commit is contained in:
714
app/Console/Commands/ZipUnsupportedArtworkOriginalsCommand.php
Normal file
714
app/Console/Commands/ZipUnsupportedArtworkOriginalsCommand.php
Normal file
@@ -0,0 +1,714 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Services\ArtworkOriginalFileLocator;
|
||||
use App\Services\Uploads\UploadStorageService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Throwable;
|
||||
use ZipArchive;
|
||||
|
||||
final class ZipUnsupportedArtworkOriginalsCommand extends Command
|
||||
{
|
||||
protected $signature = 'artworks:zip-unsupported-originals
|
||||
{--artwork-id= : Process only this artwork ID}
|
||||
{--id= : Process only this artwork ID}
|
||||
{--limit= : Stop after processing this many artworks}
|
||||
{--chunk=200 : Number of artworks to scan per batch}
|
||||
{--force : Rebuild the zip even when the artwork currently points at a supported extension or an existing zip}
|
||||
{--delete-original-object : Delete the previous original object from object storage after repointing the artwork}
|
||||
{--dry-run : Report candidate artworks without writing files or updating metadata}';
|
||||
|
||||
protected $description = 'Wrap artwork originals with unsupported file extensions into zip archives and update artwork metadata.';
|
||||
|
||||
private const ZIP_MIME = 'application/zip';
|
||||
|
||||
/**
|
||||
* Extensions that can stay as-is because they are already images or well-known archives.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
private const SUPPORTED_EXTENSIONS = [
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'png',
|
||||
'gif',
|
||||
'webp',
|
||||
'bmp',
|
||||
'tif',
|
||||
'tiff',
|
||||
'svg',
|
||||
'avif',
|
||||
'heic',
|
||||
'heif',
|
||||
'ico',
|
||||
'jfif',
|
||||
'zip',
|
||||
'rar',
|
||||
'7z',
|
||||
'7zip',
|
||||
'tar',
|
||||
'gz',
|
||||
'tgz',
|
||||
'bz2',
|
||||
'xz',
|
||||
];
|
||||
|
||||
public function handle(ArtworkOriginalFileLocator $locator, UploadStorageService $storage): int
|
||||
{
|
||||
$artworkId = $this->resolveArtworkIdOption();
|
||||
$limit = $this->option('limit') !== null ? max(1, (int) $this->option('limit')) : null;
|
||||
$chunkSize = max(1, min((int) $this->option('chunk'), 1000));
|
||||
$force = (bool) $this->option('force');
|
||||
$deleteOriginalObject = (bool) $this->option('delete-original-object');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
$this->info(sprintf(
|
||||
'Starting unsupported artwork original zip pass. chunk=%d limit=%s dry_run=%s force=%s delete_original_object=%s',
|
||||
$chunkSize,
|
||||
$limit !== null ? (string) $limit : 'all',
|
||||
$dryRun ? 'yes' : 'no',
|
||||
$force ? 'yes' : 'no',
|
||||
$deleteOriginalObject ? 'yes' : 'no',
|
||||
));
|
||||
|
||||
$query = Artwork::query()
|
||||
->withTrashed()
|
||||
->select(['id', 'title', 'slug', 'file_name', 'file_path', 'hash', 'file_ext', 'mime_type', 'file_size'])
|
||||
->orderBy('id');
|
||||
|
||||
if ($artworkId !== null) {
|
||||
$query->whereKey($artworkId);
|
||||
}
|
||||
|
||||
$processed = 0;
|
||||
$skippedSupported = 0;
|
||||
$skippedUnresolved = 0;
|
||||
$skippedMissingSource = 0;
|
||||
$wouldFixMetadata = 0;
|
||||
$wouldConvert = 0;
|
||||
$metadataFixed = 0;
|
||||
$converted = 0;
|
||||
$failed = 0;
|
||||
|
||||
$query->chunkById($chunkSize, function ($artworks) use (
|
||||
$locator,
|
||||
$storage,
|
||||
$limit,
|
||||
$force,
|
||||
$deleteOriginalObject,
|
||||
$dryRun,
|
||||
&$processed,
|
||||
&$skippedSupported,
|
||||
&$skippedUnresolved,
|
||||
&$skippedMissingSource,
|
||||
&$wouldFixMetadata,
|
||||
&$wouldConvert,
|
||||
&$metadataFixed,
|
||||
&$converted,
|
||||
&$failed,
|
||||
) {
|
||||
foreach ($artworks as $artwork) {
|
||||
if ($limit !== null && $processed >= $limit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->processArtwork($artwork, $locator, $storage, $dryRun, $deleteOriginalObject, $force);
|
||||
|
||||
match ($result) {
|
||||
'skipped_supported' => $skippedSupported++,
|
||||
'skipped_unresolved' => $skippedUnresolved++,
|
||||
'skipped_missing_source' => $skippedMissingSource++,
|
||||
'would_fix_metadata' => $wouldFixMetadata++,
|
||||
'would_convert' => $wouldConvert++,
|
||||
'fixed_metadata' => $metadataFixed++,
|
||||
'converted' => $converted++,
|
||||
default => null,
|
||||
};
|
||||
} catch (Throwable $exception) {
|
||||
$failed++;
|
||||
$this->warn(sprintf('Artwork %d failed: %s', (int) $artwork->id, $exception->getMessage()));
|
||||
}
|
||||
|
||||
$processed++;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
$this->info(sprintf(
|
||||
'Unsupported artwork original zip pass complete. processed=%d skipped_supported=%d skipped_unresolved=%d skipped_missing_source=%d would_fix_metadata=%d would_convert=%d metadata_fixed=%d converted=%d failed=%d',
|
||||
$processed,
|
||||
$skippedSupported,
|
||||
$skippedUnresolved,
|
||||
$skippedMissingSource,
|
||||
$wouldFixMetadata,
|
||||
$wouldConvert,
|
||||
$metadataFixed,
|
||||
$converted,
|
||||
$failed,
|
||||
));
|
||||
|
||||
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
private function resolveArtworkIdOption(): ?int
|
||||
{
|
||||
$artworkId = $this->option('artwork-id');
|
||||
if ($artworkId !== null) {
|
||||
return max(1, (int) $artworkId);
|
||||
}
|
||||
|
||||
$legacyId = $this->option('id');
|
||||
if ($legacyId !== null) {
|
||||
return max(1, (int) $legacyId);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function processArtwork(Artwork $artwork, ArtworkOriginalFileLocator $locator, UploadStorageService $storage, bool $dryRun, bool $deleteOriginalObject, bool $force): string
|
||||
{
|
||||
$metadataExtension = $this->normalizeExtension((string) $artwork->file_ext);
|
||||
if (! $force && $this->isSupportedExtension($metadataExtension)) {
|
||||
return 'skipped_supported';
|
||||
}
|
||||
|
||||
$resolvedLocalPath = $locator->resolveLocalPath($artwork);
|
||||
$resolvedObjectPath = $locator->resolveObjectPath($artwork);
|
||||
|
||||
$hash = strtolower(trim((string) $artwork->hash));
|
||||
if (! $this->isValidHash($hash)) {
|
||||
$this->line(sprintf('Artwork %d skipped: invalid or missing hash.', (int) $artwork->id));
|
||||
$this->writeArtworkContext($artwork);
|
||||
|
||||
return 'skipped_unresolved';
|
||||
}
|
||||
|
||||
$targetLocalPath = $storage->localOriginalPath($hash, $hash . '.zip');
|
||||
$targetObjectPath = $storage->objectPathForVariant('original', $hash, $hash . '.zip');
|
||||
$source = $this->prepareSourceFile($resolvedLocalPath, $resolvedObjectPath, $storage, $hash, $force);
|
||||
|
||||
if ($source === null) {
|
||||
$this->line(sprintf('Artwork %d skipped: source file not found.', (int) $artwork->id));
|
||||
$this->writeArtworkContext($artwork);
|
||||
$this->writeVerbosePaths($resolvedLocalPath, $targetLocalPath, $resolvedObjectPath, $targetObjectPath);
|
||||
|
||||
return 'skipped_missing_source';
|
||||
}
|
||||
|
||||
$sourceExtension = $this->detectSourceExtension($source['path'], $resolvedObjectPath);
|
||||
|
||||
if (! $force && $this->isSupportedExtension($sourceExtension)) {
|
||||
if ($dryRun) {
|
||||
$this->line(sprintf(
|
||||
'Artwork %d would fix metadata only: file_ext=%s -> %s',
|
||||
(int) $artwork->id,
|
||||
$metadataExtension !== '' ? $metadataExtension : '(empty)',
|
||||
$sourceExtension,
|
||||
));
|
||||
$this->writeArtworkContext($artwork);
|
||||
|
||||
return 'would_fix_metadata';
|
||||
}
|
||||
|
||||
$size = $this->detectFileSize($source['path'], $artwork->file_size);
|
||||
$mime = $this->detectMimeType($source['path'], $artwork->mime_type, $sourceExtension);
|
||||
$updatedFileName = $this->resolveFileNameWithExtension((string) ($artwork->file_name ?? ''), $sourceExtension);
|
||||
|
||||
$this->persistArtworkMetadata((int) $artwork->id, $resolvedObjectPath !== '' ? $resolvedObjectPath : null, $sourceExtension, $mime, $size, $updatedFileName);
|
||||
$this->info(sprintf(
|
||||
'Artwork %d metadata fixed: file_ext=%s -> %s',
|
||||
(int) $artwork->id,
|
||||
$metadataExtension !== '' ? $metadataExtension : '(empty)',
|
||||
$sourceExtension,
|
||||
));
|
||||
$this->writeArtworkContext($artwork);
|
||||
|
||||
return 'fixed_metadata';
|
||||
}
|
||||
|
||||
if ($force && $this->isSupportedExtension($sourceExtension)) {
|
||||
$this->line(sprintf(
|
||||
'Artwork %d skipped: force requested but no non-archive source was found.',
|
||||
(int) $artwork->id,
|
||||
));
|
||||
$this->writeArtworkContext($artwork);
|
||||
$this->writeVerbosePaths($source['path'], $targetLocalPath, $resolvedObjectPath, $targetObjectPath);
|
||||
|
||||
return 'skipped_supported';
|
||||
}
|
||||
|
||||
try {
|
||||
if ($dryRun) {
|
||||
$this->line(sprintf(
|
||||
'Artwork %d would be archived: file_ext=%s -> zip',
|
||||
(int) $artwork->id,
|
||||
$metadataExtension !== '' ? $metadataExtension : '(empty)',
|
||||
));
|
||||
$this->writeArtworkContext($artwork);
|
||||
$this->writeVerbosePaths($source['path'], $targetLocalPath, $resolvedObjectPath, $targetObjectPath);
|
||||
|
||||
return 'would_convert';
|
||||
}
|
||||
|
||||
$archiveEntryName = $this->resolveArchiveEntryName($artwork, $metadataExtension, $sourceExtension);
|
||||
$temporaryZipPath = $this->createZipArchive($source['path'], $archiveEntryName);
|
||||
|
||||
try {
|
||||
$this->publishZipArchive($temporaryZipPath, $targetLocalPath, $targetObjectPath, $storage);
|
||||
$size = (int) (filesize($targetLocalPath) ?: 0);
|
||||
$updatedFileName = $this->resolveFileNameWithExtension((string) ($artwork->file_name ?? ''), 'zip');
|
||||
|
||||
$this->persistArtworkMetadata((int) $artwork->id, $targetObjectPath, 'zip', self::ZIP_MIME, $size, $updatedFileName);
|
||||
$this->deleteLegacySource($resolvedLocalPath, $targetLocalPath, $resolvedObjectPath, $targetObjectPath, $storage, $deleteOriginalObject);
|
||||
} catch (Throwable $exception) {
|
||||
$this->cleanupTargetArtifacts($targetLocalPath, $targetObjectPath, $storage);
|
||||
|
||||
throw $exception;
|
||||
} finally {
|
||||
File::delete($temporaryZipPath);
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'Artwork %d archived to zip: file_ext=%s -> zip',
|
||||
(int) $artwork->id,
|
||||
$metadataExtension !== '' ? $metadataExtension : '(empty)',
|
||||
));
|
||||
$this->writeArtworkContext($artwork);
|
||||
$deletedOldObjectPath = $deleteOriginalObject && $resolvedObjectPath !== '' && $resolvedObjectPath !== $targetObjectPath
|
||||
? $resolvedObjectPath
|
||||
: '';
|
||||
$keptOldObjectPath = ! $deleteOriginalObject && $resolvedObjectPath !== '' && $resolvedObjectPath !== $targetObjectPath
|
||||
? $resolvedObjectPath
|
||||
: '';
|
||||
|
||||
$this->writeVerbosePaths($source['path'], $targetLocalPath, $resolvedObjectPath, $targetObjectPath, $deletedOldObjectPath, $keptOldObjectPath);
|
||||
|
||||
return 'converted';
|
||||
} finally {
|
||||
if ($source['temporary']) {
|
||||
File::delete($source['path']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{path: string, temporary: bool}|null
|
||||
*/
|
||||
private function prepareSourceFile(string $resolvedLocalPath, string $resolvedObjectPath, UploadStorageService $storage, string $hash, bool $force): ?array
|
||||
{
|
||||
if ($force) {
|
||||
$forcedSourcePath = $this->resolveForceSourcePath($hash);
|
||||
if ($forcedSourcePath !== '') {
|
||||
return [
|
||||
'path' => $forcedSourcePath,
|
||||
'temporary' => false,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if ($resolvedLocalPath !== '' && File::isFile($resolvedLocalPath)) {
|
||||
return [
|
||||
'path' => $resolvedLocalPath,
|
||||
'temporary' => false,
|
||||
];
|
||||
}
|
||||
|
||||
$backupSourcePath = $this->resolveReadonlyBackupSourcePath($resolvedObjectPath);
|
||||
if ($backupSourcePath !== '' && File::isFile($backupSourcePath)) {
|
||||
return [
|
||||
'path' => $backupSourcePath,
|
||||
'temporary' => false,
|
||||
];
|
||||
}
|
||||
|
||||
if ($resolvedObjectPath === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$disk = Storage::disk($storage->objectDiskName());
|
||||
if (! $disk->exists($resolvedObjectPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stream = $disk->readStream($resolvedObjectPath);
|
||||
if (! is_resource($stream)) {
|
||||
throw new RuntimeException('Unable to open source object stream.');
|
||||
}
|
||||
|
||||
$temporaryPath = tempnam(sys_get_temp_dir(), 'art-src-');
|
||||
if ($temporaryPath === false) {
|
||||
fclose($stream);
|
||||
|
||||
throw new RuntimeException('Unable to allocate a temporary source file.');
|
||||
}
|
||||
|
||||
$target = fopen($temporaryPath, 'wb');
|
||||
if (! is_resource($target)) {
|
||||
fclose($stream);
|
||||
File::delete($temporaryPath);
|
||||
|
||||
throw new RuntimeException('Unable to open a temporary source file for writing.');
|
||||
}
|
||||
|
||||
try {
|
||||
$copied = stream_copy_to_stream($stream, $target);
|
||||
} finally {
|
||||
fclose($stream);
|
||||
fclose($target);
|
||||
}
|
||||
|
||||
if ($copied === false || $copied <= 0 || ! File::isFile($temporaryPath)) {
|
||||
File::delete($temporaryPath);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'path' => $temporaryPath,
|
||||
'temporary' => true,
|
||||
];
|
||||
}
|
||||
|
||||
private function createZipArchive(string $sourcePath, string $archiveEntryName): string
|
||||
{
|
||||
$temporaryPath = tempnam(sys_get_temp_dir(), 'art-zip-');
|
||||
if ($temporaryPath === false) {
|
||||
throw new RuntimeException('Unable to allocate a temporary zip file.');
|
||||
}
|
||||
|
||||
$zip = new ZipArchive();
|
||||
$opened = $zip->open($temporaryPath, ZipArchive::CREATE | ZipArchive::OVERWRITE);
|
||||
if ($opened !== true) {
|
||||
File::delete($temporaryPath);
|
||||
|
||||
throw new RuntimeException('Unable to create zip archive.');
|
||||
}
|
||||
|
||||
try {
|
||||
if (! $zip->addFile($sourcePath, $archiveEntryName)) {
|
||||
throw new RuntimeException('Unable to add artwork original to zip archive.');
|
||||
}
|
||||
} finally {
|
||||
$zip->close();
|
||||
}
|
||||
|
||||
if (! File::isFile($temporaryPath)) {
|
||||
throw new RuntimeException('Zip archive was not written to disk.');
|
||||
}
|
||||
|
||||
return $temporaryPath;
|
||||
}
|
||||
|
||||
private function publishZipArchive(string $temporaryZipPath, string $targetLocalPath, string $targetObjectPath, UploadStorageService $storage): void
|
||||
{
|
||||
File::ensureDirectoryExists(dirname($targetLocalPath));
|
||||
File::delete($targetLocalPath);
|
||||
|
||||
if (! File::copy($temporaryZipPath, $targetLocalPath)) {
|
||||
throw new RuntimeException('Unable to write local zip archive.');
|
||||
}
|
||||
|
||||
$storage->putObjectFromPath($targetLocalPath, $targetObjectPath, self::ZIP_MIME);
|
||||
}
|
||||
|
||||
private function cleanupTargetArtifacts(string $targetLocalPath, string $targetObjectPath, UploadStorageService $storage): void
|
||||
{
|
||||
$storage->deleteLocalFile($targetLocalPath);
|
||||
$storage->deleteObject($targetObjectPath);
|
||||
}
|
||||
|
||||
private function deleteLegacySource(string $resolvedLocalPath, string $targetLocalPath, string $resolvedObjectPath, string $targetObjectPath, UploadStorageService $storage, bool $deleteOriginalObject): void
|
||||
{
|
||||
if ($resolvedLocalPath !== '' && $this->samePath($resolvedLocalPath, $targetLocalPath) === false) {
|
||||
$storage->deleteLocalFile($resolvedLocalPath);
|
||||
}
|
||||
|
||||
if ($deleteOriginalObject && $resolvedObjectPath !== '' && $resolvedObjectPath !== $targetObjectPath) {
|
||||
$storage->deleteObject($resolvedObjectPath);
|
||||
}
|
||||
}
|
||||
|
||||
private function persistArtworkMetadata(int $artworkId, ?string $filePath, string $fileExt, string $mimeType, int $fileSize, ?string $fileName = null): void
|
||||
{
|
||||
$values = [
|
||||
'file_path' => $filePath,
|
||||
'file_ext' => $fileExt,
|
||||
'mime_type' => $mimeType,
|
||||
'file_size' => max(0, $fileSize),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
|
||||
if ($fileName !== null && trim($fileName) !== '') {
|
||||
$values['file_name'] = $fileName;
|
||||
}
|
||||
|
||||
DB::table('artworks')
|
||||
->where('id', $artworkId)
|
||||
->update($values);
|
||||
}
|
||||
|
||||
private function resolveArchiveEntryName(Artwork $artwork, string $metadataExtension, string $sourceExtension): string
|
||||
{
|
||||
$candidate = trim((string) pathinfo((string) $artwork->file_name, PATHINFO_FILENAME));
|
||||
$candidate = str_replace(['/', '\\'], '-', $candidate);
|
||||
$candidate = trim((string) preg_replace('/[\x00-\x1F\x7F]/', '', $candidate));
|
||||
$candidate = trim($candidate, ". \t\n\r\0\x0B");
|
||||
|
||||
$extension = $sourceExtension !== '' ? $sourceExtension : $metadataExtension;
|
||||
|
||||
if ($candidate !== '' && $candidate !== '.' && $candidate !== '..') {
|
||||
return $extension !== ''
|
||||
? $candidate . '.' . $extension
|
||||
: $candidate;
|
||||
}
|
||||
|
||||
if ($extension !== '') {
|
||||
return (string) $artwork->hash . '.' . $extension;
|
||||
}
|
||||
|
||||
return ((string) $artwork->hash !== '' ? (string) $artwork->hash : 'artwork') . '.bin';
|
||||
}
|
||||
|
||||
private function detectSourceExtension(string $resolvedLocalPath, string $resolvedObjectPath): string
|
||||
{
|
||||
$path = $resolvedLocalPath !== '' ? $resolvedLocalPath : $resolvedObjectPath;
|
||||
|
||||
return $this->normalizeExtension((string) pathinfo($path, PATHINFO_EXTENSION));
|
||||
}
|
||||
|
||||
private function detectMimeType(string $resolvedLocalPath, ?string $fallbackMimeType, string $extension): string
|
||||
{
|
||||
if ($resolvedLocalPath !== '' && File::isFile($resolvedLocalPath)) {
|
||||
$detected = File::mimeType($resolvedLocalPath);
|
||||
if (is_string($detected) && $detected !== '') {
|
||||
return $detected;
|
||||
}
|
||||
}
|
||||
|
||||
$fallback = trim((string) $fallbackMimeType);
|
||||
if ($fallback !== '') {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
return match ($extension) {
|
||||
'jpg', 'jpeg' => 'image/jpeg',
|
||||
'png' => 'image/png',
|
||||
'gif' => 'image/gif',
|
||||
'webp' => 'image/webp',
|
||||
'bmp' => 'image/bmp',
|
||||
'tif', 'tiff' => 'image/tiff',
|
||||
'svg' => 'image/svg+xml',
|
||||
'avif' => 'image/avif',
|
||||
'heic' => 'image/heic',
|
||||
'heif' => 'image/heif',
|
||||
'ico' => 'image/x-icon',
|
||||
'zip' => self::ZIP_MIME,
|
||||
'rar' => 'application/vnd.rar',
|
||||
'7z', '7zip' => 'application/x-7z-compressed',
|
||||
'tar' => 'application/x-tar',
|
||||
'gz', 'tgz' => 'application/gzip',
|
||||
'bz2' => 'application/x-bzip2',
|
||||
'xz' => 'application/x-xz',
|
||||
default => 'application/octet-stream',
|
||||
};
|
||||
}
|
||||
|
||||
private function detectFileSize(string $resolvedLocalPath, ?int $fallbackSize): int
|
||||
{
|
||||
if ($resolvedLocalPath !== '' && File::isFile($resolvedLocalPath)) {
|
||||
$size = filesize($resolvedLocalPath);
|
||||
if ($size !== false) {
|
||||
return (int) $size;
|
||||
}
|
||||
}
|
||||
|
||||
return max(0, (int) $fallbackSize);
|
||||
}
|
||||
|
||||
private function resolveFileNameWithExtension(string $fileName, string $extension): string
|
||||
{
|
||||
$name = trim($fileName);
|
||||
$name = str_replace(['/', '\\'], '-', $name);
|
||||
$name = preg_replace('/[\x00-\x1F\x7F]/', '', $name) ?? '';
|
||||
$name = preg_replace('/\s+/', ' ', $name) ?? '';
|
||||
$name = trim((string) $name, ". \t\n\r\0\x0B");
|
||||
|
||||
$baseName = trim((string) pathinfo($name, PATHINFO_FILENAME), ". \t\n\r\0\x0B");
|
||||
if ($baseName === '') {
|
||||
$baseName = 'artwork';
|
||||
}
|
||||
|
||||
$normalizedExtension = $this->normalizeExtension($extension);
|
||||
|
||||
return $normalizedExtension !== ''
|
||||
? $baseName . '.' . $normalizedExtension
|
||||
: $baseName;
|
||||
}
|
||||
|
||||
private function normalizeExtension(string $extension): string
|
||||
{
|
||||
return strtolower(ltrim(trim($extension), '.'));
|
||||
}
|
||||
|
||||
private function isSupportedExtension(string $extension): bool
|
||||
{
|
||||
return $extension !== '' && in_array($extension, self::SUPPORTED_EXTENSIONS, true);
|
||||
}
|
||||
|
||||
private function isValidHash(string $hash): bool
|
||||
{
|
||||
return $hash !== '' && preg_match('/^[a-f0-9]+$/', $hash) === 1;
|
||||
}
|
||||
|
||||
private function samePath(string $left, string $right): bool
|
||||
{
|
||||
$normalizedLeft = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $left);
|
||||
$normalizedRight = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $right);
|
||||
|
||||
return $normalizedLeft === $normalizedRight;
|
||||
}
|
||||
|
||||
private function resolveForceSourcePath(string $hash): string
|
||||
{
|
||||
if (! $this->isValidHash($hash)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
foreach ($this->candidateOriginalRoots() as $root) {
|
||||
$candidatePath = $this->findNonZipSourceInRoot($root, $hash);
|
||||
if ($candidatePath !== '') {
|
||||
return $candidatePath;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function candidateOriginalRoots(): array
|
||||
{
|
||||
$roots = [
|
||||
trim((string) config('uploads.local_originals_root', '')),
|
||||
trim((string) config('uploads.readonly_backup_originals_root', '')),
|
||||
];
|
||||
|
||||
$normalizedRoots = [];
|
||||
|
||||
foreach ($roots as $root) {
|
||||
if ($root === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalizedRoot = rtrim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $root), DIRECTORY_SEPARATOR);
|
||||
if ($normalizedRoot === '' || in_array($normalizedRoot, $normalizedRoots, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalizedRoots[] = $normalizedRoot;
|
||||
}
|
||||
|
||||
return $normalizedRoots;
|
||||
}
|
||||
|
||||
private function findNonZipSourceInRoot(string $root, string $hash): string
|
||||
{
|
||||
$directory = $root
|
||||
. DIRECTORY_SEPARATOR . substr($hash, 0, 2)
|
||||
. DIRECTORY_SEPARATOR . substr($hash, 2, 2);
|
||||
|
||||
if (! File::isDirectory($directory)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$matches = File::glob($directory . DIRECTORY_SEPARATOR . $hash . '.*');
|
||||
if (! is_array($matches)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
foreach ($matches as $path) {
|
||||
if (! is_string($path) || ! File::isFile($path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$extension = $this->normalizeExtension((string) pathinfo($path, PATHINFO_EXTENSION));
|
||||
if ($extension === '' || $extension === 'zip') {
|
||||
continue;
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private function resolveReadonlyBackupSourcePath(string $resolvedObjectPath): string
|
||||
{
|
||||
$root = trim((string) config('uploads.readonly_backup_originals_root', ''));
|
||||
if ($root === '' || $resolvedObjectPath === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$normalizedRoot = rtrim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $root), DIRECTORY_SEPARATOR);
|
||||
$filename = (string) pathinfo($resolvedObjectPath, PATHINFO_BASENAME);
|
||||
$hash = strtolower((string) pathinfo($filename, PATHINFO_FILENAME));
|
||||
$extension = $this->normalizeExtension((string) pathinfo($filename, PATHINFO_EXTENSION));
|
||||
|
||||
if (! $this->isValidHash($hash) || $extension === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $normalizedRoot
|
||||
. DIRECTORY_SEPARATOR . substr($hash, 0, 2)
|
||||
. DIRECTORY_SEPARATOR . substr($hash, 2, 2)
|
||||
. DIRECTORY_SEPARATOR . $hash . '.' . $extension;
|
||||
}
|
||||
|
||||
private function writeVerbosePaths(
|
||||
string $sourcePath,
|
||||
string $targetLocalPath,
|
||||
string $sourceObjectPath = '',
|
||||
string $targetObjectPath = '',
|
||||
string $deletedOldObjectPath = '',
|
||||
string $keptOldObjectPath = '',
|
||||
): void
|
||||
{
|
||||
$displaySourcePath = $sourcePath !== '' ? $sourcePath : '(unresolved local source path)';
|
||||
|
||||
$this->line(' source_file: ' . $displaySourcePath);
|
||||
if ($sourceObjectPath !== '') {
|
||||
$this->line(' source_object: ' . $sourceObjectPath, null, OutputInterface::VERBOSITY_VERBOSE);
|
||||
}
|
||||
$this->line(' new_zip_file: ' . $targetLocalPath);
|
||||
if ($targetObjectPath !== '') {
|
||||
$this->line(' new_zip_object: ' . $targetObjectPath);
|
||||
}
|
||||
if ($deletedOldObjectPath !== '') {
|
||||
$this->line(' deleted_old_object: ' . $deletedOldObjectPath, null, OutputInterface::VERBOSITY_VERBOSE);
|
||||
}
|
||||
if ($keptOldObjectPath !== '') {
|
||||
$this->line(' kept_original_object: ' . $keptOldObjectPath);
|
||||
}
|
||||
}
|
||||
|
||||
private function writeArtworkContext(Artwork $artwork): void
|
||||
{
|
||||
$this->line(' title: ' . trim((string) ($artwork->title ?? '')));
|
||||
$this->line(' artwork_url: ' . route('art.show', [
|
||||
'id' => (int) $artwork->id,
|
||||
'slug' => (string) ($artwork->slug ?? ''),
|
||||
]));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user