*/ public function ensureSquareThumbnail(Artwork $artwork, bool $force = false, bool $dryRun = false): array { $hash = strtolower((string) ($artwork->hash ?? '')); if ($hash === '') { throw new RuntimeException('Artwork hash is required to generate a square thumbnail.'); } $existing = DB::table('artwork_files') ->where('artwork_id', $artwork->id) ->where('variant', 'sq') ->first(['path']); if ($existing !== null && ! $force) { return [ 'status' => 'skipped', 'reason' => 'already_exists', 'artwork_id' => $artwork->id, 'path' => (string) ($existing->path ?? ''), ]; } $resolved = $this->resolveBestSource($artwork); if ($dryRun) { return [ 'status' => 'dry_run', 'artwork_id' => $artwork->id, 'source_variant' => $resolved['variant'], 'source_path' => $resolved['source_path'], 'object_path' => $this->storage->objectPathForVariant('sq', $hash, $hash . '.webp'), ]; } try { $asset = $this->derivatives->generateSquareDerivative($resolved['source_path'], $hash, [ 'context' => ['artwork' => $artwork], ]); $this->artworkFiles->upsert($artwork->id, 'sq', $asset['path'], $asset['mime'], $asset['size']); $this->cdnPurge->purgeArtworkObjectPaths([$asset['path']], [ 'artwork_id' => $artwork->id, 'reason' => 'square_thumbnail_regenerated', ]); if (! is_string($artwork->thumb_ext) || trim($artwork->thumb_ext) === '') { $artwork->forceFill(['thumb_ext' => 'webp'])->saveQuietly(); } return [ 'status' => 'generated', 'artwork_id' => $artwork->id, 'path' => $asset['path'], 'source_variant' => $resolved['variant'], 'crop_mode' => $asset['result']?->cropMode, ]; } finally { if (($resolved['cleanup'] ?? false) === true) { File::delete($resolved['source_path']); } } } /** * @return array{variant: string, source_path: string, cleanup: bool} */ private function resolveBestSource(Artwork $artwork): array { $hash = strtolower((string) ($artwork->hash ?? '')); $files = DB::table('artwork_files') ->where('artwork_id', $artwork->id) ->pluck('path', 'variant') ->all(); $variants = ['orig_image', 'orig', 'xl', 'lg', 'md', 'sm', 'xs']; foreach ($variants as $variant) { $path = $files[$variant] ?? null; if (! is_string($path) || trim($path) === '') { continue; } if ($variant === 'orig_image' || $variant === 'orig') { $filename = basename($path); $localPath = $this->storage->localOriginalPath($hash, $filename); if (is_file($localPath)) { return [ 'variant' => $variant, 'source_path' => $localPath, 'cleanup' => false, ]; } } $temporary = $this->downloadToTempFile($path, pathinfo($path, PATHINFO_EXTENSION) ?: 'webp'); if ($temporary !== null) { return [ 'variant' => $variant, 'source_path' => $temporary, 'cleanup' => true, ]; } } $directSource = $this->resolveArtworkFilePathSource($artwork); if ($directSource !== null) { return $directSource; } $canonicalDerivativeSource = $this->resolveCanonicalDerivativeSource($artwork); if ($canonicalDerivativeSource !== null) { return $canonicalDerivativeSource; } throw new RuntimeException(sprintf('No usable source image was found for artwork %d.', (int) $artwork->id)); } /** * @return array{variant: string, source_path: string, cleanup: bool}|null */ private function resolveArtworkFilePathSource(Artwork $artwork): ?array { $relativePath = trim((string) ($artwork->file_path ?? ''), '/'); if ($relativePath === '') { return null; } foreach ($this->localFilePathCandidates($relativePath) as $candidate) { if (is_file($candidate)) { return [ 'variant' => 'file_path', 'source_path' => $candidate, 'cleanup' => false, ]; } } $downloaded = $this->downloadUrlToTempFile($this->cdnUrlForPath($relativePath), pathinfo($relativePath, PATHINFO_EXTENSION)); if ($downloaded === null) { return null; } return [ 'variant' => 'file_path', 'source_path' => $downloaded, 'cleanup' => true, ]; } /** * @return array{variant: string, source_path: string, cleanup: bool}|null */ private function resolveCanonicalDerivativeSource(Artwork $artwork): ?array { $hash = strtolower((string) ($artwork->hash ?? '')); $thumbExt = strtolower(ltrim((string) ($artwork->thumb_ext ?? ''), '.')); if ($hash === '' || $thumbExt === '') { return null; } foreach (['xl', 'lg', 'md', 'sm', 'xs'] as $variant) { $url = ThumbnailService::fromHash($hash, $thumbExt, $variant); if (! is_string($url) || $url === '') { continue; } $downloaded = $this->downloadUrlToTempFile($url, $thumbExt); if ($downloaded === null) { continue; } return [ 'variant' => $variant, 'source_path' => $downloaded, 'cleanup' => true, ]; } return null; } /** * @return array */ private function localFilePathCandidates(string $relativePath): array { $normalizedPath = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $relativePath); return array_values(array_unique([ $normalizedPath, base_path($normalizedPath), public_path($normalizedPath), storage_path('app/public' . DIRECTORY_SEPARATOR . $normalizedPath), storage_path('app/private' . DIRECTORY_SEPARATOR . $normalizedPath), ])); } private function cdnUrlForPath(string $relativePath): string { return rtrim((string) config('cdn.files_url', 'https://cdn.skinbase.org'), '/') . '/' . ltrim($relativePath, '/'); } private function downloadUrlToTempFile(string $url, string $extension = ''): ?string { $context = stream_context_create([ 'http' => [ 'method' => 'GET', 'timeout' => 30, 'ignore_errors' => true, 'header' => implode("\r\n", [ 'User-Agent: Skinbase Nova square-thumb backfill', 'Accept: image/*,*/*;q=0.8', 'Accept-Encoding: identity', 'Connection: close', ]) . "\r\n", ], 'ssl' => [ 'verify_peer' => true, 'verify_peer_name' => true, ], ]); $contents = @file_get_contents($url, false, $context); $headers = $http_response_header ?? []; if (! is_string($contents) || $contents === '' || ! $this->isSuccessfulHttpResponse($url, $headers)) { return null; } if (! is_string($contents) || $contents === '') { return null; } $resolvedExtension = trim($extension) !== '' ? trim($extension) : $this->extensionFromContentType($this->contentTypeFromHeaders($headers)); return $this->writeTemporaryFile($contents, $resolvedExtension); } /** * @param array $headers */ private function isSuccessfulHttpResponse(string $url, array $headers): bool { if ($headers === [] && parse_url($url, PHP_URL_SCHEME) === 'file') { return true; } $statusLine = $headers[0] ?? ''; if (! is_string($statusLine) || ! preg_match('/\s(\d{3})\s/', $statusLine, $matches)) { return false; } $statusCode = (int) ($matches[1] ?? 0); return $statusCode >= 200 && $statusCode < 300; } /** * @param array $headers */ private function contentTypeFromHeaders(array $headers): string { foreach ($headers as $header) { if (! is_string($header) || stripos($header, 'Content-Type:') !== 0) { continue; } return trim(substr($header, strlen('Content-Type:'))); } return ''; } private function writeTemporaryFile(string $contents, string $extension = ''): string { $temp = tempnam(sys_get_temp_dir(), 'sq-thumb-'); if ($temp === false) { throw new RuntimeException('Unable to allocate a temporary file for square thumbnail generation.'); } $normalizedExtension = trim((string) $extension); $path = $normalizedExtension !== '' ? $temp . '.' . $normalizedExtension : $temp; if ($normalizedExtension !== '') { rename($temp, $path); } File::put($path, $contents); return $path; } private function extensionFromContentType(string $contentType): string { $normalized = strtolower(trim(strtok($contentType, ';') ?: '')); return match ($normalized) { 'image/jpeg', 'image/jpg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp', 'image/gif' => 'gif', default => '', }; } private function downloadToTempFile(string $objectPath, string $extension): ?string { $contents = $this->storage->readObject($objectPath); if (! is_string($contents) || $contents === '') { return null; } return $this->writeTemporaryFile($contents, $extension); } }