Files
SkinbaseNova/app/Console/Commands/ZipUnsupportedArtworkOriginalsCommand.php

714 lines
25 KiB
PHP

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