714 lines
25 KiB
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 ?? ''),
|
|
]));
|
|
}
|
|
} |