tenants->fetchById($tenantId); if ($tenant === null) { return ['enabled' => false, 'storage' => false, 'generation' => false]; } $configuration = $tenant->getConfiguration()->preview(); $enabled = $configuration->enabled(); $storage = $enabled && $tenant->getConfiguration()->stores()->store($configuration->store()) !== null; $generation = $storage && $this->previewProviders() !== []; if ($enabled && !$storage) { $this->logger->debug('Preview storage is not configured', ['tenantId' => $tenantId]); } elseif ($enabled && $storage && !$generation) { $this->logger->debug('No preview generation provider is available', ['tenantId' => $tenantId]); } return compact('enabled', 'storage', 'generation'); } public function fetch( string $tenantId, PreviewSource $source, string $variant = 'inline', ): ?Preview { $tenant = $this->tenants->fetchById($tenantId); if ($tenant === null) { return null; } $configuration = $tenant->getConfiguration()->preview(); $variantConfiguration = $configuration->variants()->variant($variant); if ( !$configuration->enabled() || $variantConfiguration === null || ($source->size !== null && $source->size > $configuration->maxSourceSize()) || $tenant->getConfiguration()->stores()->store($configuration->store()) === null ) { return null; } $request = new PreviewRequest( maxWidth: $variantConfiguration->width, maxHeight: $variantConfiguration->height, preferredMimeType: $variantConfiguration->format, quality: $variantConfiguration->quality, maxSourceSize: $configuration->maxSourceSize(), ); try { $cached = $this->readCache( $tenantId, $configuration->store(), $source, $request, ); if ($cached !== null) { return $cached; } $provider = $this->selectProvider($source->mimeType, $request); if ($provider === null) { return null; } $result = $provider->generate($source, $request); $blob = $this->writeCache( $tenantId, $configuration->store(), $source, $request, $result, ); return $this->readStoredPreview( $tenantId, $configuration->store(), $blob, $request, ); } catch (Throwable $exception) { $this->logger->warning('Preview unavailable', [ 'tenantId' => $tenantId, 'sourceType' => $source->sourceType, 'variant' => $variant, 'exception' => $exception, ]); return null; } } private function readCache( string $tenantId, string $store, PreviewSource $source, PreviewRequest $request, ): ?Preview { $key = $this->cacheKey($tenantId, $source, $request); $blob = $this->stores->stat($tenantId, $store, $key); if (!$this->validBlob($blob, $key, $request)) { return null; } return $this->readStoredPreview($tenantId, $store, $blob, $request); } private function readStoredPreview( string $tenantId, string $store, BlobInfo $blob, PreviewRequest $request, ): ?Preview { $resource = $this->stores->read($tenantId, $store, $blob->key); if ($resource === null) { return null; } try { if (MimeType::normalize($resource->mimeType()) !== $request->preferredMimeType) { return null; } } catch (Throwable) { return null; } return new Preview( resource: $resource, size: $blob->size, etag: $blob->etag, width: $blob->attributes['width'] ?? null, height: $blob->attributes['height'] ?? null, ); } private function writeCache( string $tenantId, string $store, PreviewSource $source, PreviewRequest $request, PreviewResult $result, ): BlobInfo { $outputMimeType = MimeType::normalize($result->resource->mimeType()); if ($outputMimeType !== $request->preferredMimeType) { throw new PreviewGenerationException('Generated preview MIME does not match the requested output MIME'); } if ( ($result->width !== null && $result->width > $request->maxWidth) || ($result->height !== null && $result->height > $request->maxHeight) ) { throw new PreviewGenerationException('Generated preview dimensions exceed the requested limits'); } return $this->stores->write( $tenantId, $store, $this->cacheKey($tenantId, $source, $request), $result->resource, ['width' => $result->width, 'height' => $result->height], ); } private function cacheKey( string $tenantId, PreviewSource $source, PreviewRequest $request, ): string { if (trim($tenantId) === '') { throw new InvalidArgumentException('Preview cache keys require a tenant identifier'); } $canonical = json_encode([ 'tenantId' => $tenantId, 'source' => [ 'type' => $source->sourceType, 'identity' => $source->identity, 'signature' => $source->signature, 'mimeType' => $source->mimeType, ], 'request' => [ 'maxWidth' => $request->maxWidth, 'maxHeight' => $request->maxHeight, 'preferredMimeType' => $request->preferredMimeType, 'quality' => $request->quality, ], ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION); $digest = hash('sha256', $canonical); return "generated/{$source->sourceType}/" . substr($digest, 0, 2) . "/{$digest}"; } private function validBlob(?BlobInfo $blob, string $key, PreviewRequest $request): bool { if ($blob === null || $blob->key !== $key || $blob->size < 1) { return false; } $width = $blob->attributes['width'] ?? null; $height = $blob->attributes['height'] ?? null; if ( ($width !== null && (!is_int($width) || $width < 1 || $width > $request->maxWidth)) || ($height !== null && (!is_int($height) || $height < 1 || $height > $request->maxHeight)) ) { return false; } try { return MimeType::normalize($blob->mimeType) === $request->preferredMimeType; } catch (Throwable) { return false; } } private function selectProvider( string $sourceMimeType, PreviewRequest $request, ): ?PreviewProviderInterface { $sourceMimeType = MimeType::normalize($sourceMimeType); $candidates = []; foreach ($this->previewProviders() as $identifier => $provider) { if ( !$provider instanceof PreviewProviderInterface || !$provider->supports($sourceMimeType, $request) ) { continue; } $candidates[] = ['identifier' => $identifier, 'provider' => $provider]; } if ($candidates === []) { return null; } usort($candidates, static function (array $left, array $right): int { return ($right['provider']->priority() <=> $left['provider']->priority()) ?: strcmp($left['identifier'], $right['identifier']); }); return $candidates[0]['provider']; } /** @return array */ private function previewProviders(): array { return array_filter( $this->providers->providers(ProviderInterface::TYPE_PREVIEW), static fn(ProviderInterface $provider): bool => $provider instanceof PreviewProviderInterface, ); } }