*/ 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 */ 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 ?? ''), ])); } }