4dd09cc7ec
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
285 lines
9.4 KiB
PHP
285 lines
9.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace KTXC\Preview;
|
|
|
|
use InvalidArgumentException;
|
|
use KTXC\Resource\ProviderManager;
|
|
use KTXC\Service\TenantService;
|
|
use KTXC\SystemStore\SystemStoreManager;
|
|
use KTXF\Preview\MimeType;
|
|
use KTXF\Preview\PreviewGenerationException;
|
|
use KTXF\Preview\PreviewRequest;
|
|
use KTXF\Preview\PreviewResult;
|
|
use KTXF\Preview\PreviewSource;
|
|
use KTXF\Preview\Provider\PreviewProviderInterface;
|
|
use KTXF\Resource\Provider\ProviderInterface;
|
|
use KTXF\SystemStore\BlobInfo;
|
|
use Psr\Log\LoggerInterface;
|
|
use Throwable;
|
|
|
|
final readonly class PreviewManager
|
|
{
|
|
public function __construct(
|
|
private TenantService $tenants,
|
|
private ProviderManager $providers,
|
|
private SystemStoreManager $stores,
|
|
private LoggerInterface $logger,
|
|
) {
|
|
}
|
|
|
|
/** @return array{enabled:bool,storage:bool,generation:bool} */
|
|
public function availability(string $tenantId): array
|
|
{
|
|
$tenant = $this->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([
|
|
'version' => 2, // Regenerate previews with opaque document pages and outlines.
|
|
'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<string, PreviewProviderInterface> */
|
|
private function previewProviders(): array
|
|
{
|
|
return array_filter(
|
|
$this->providers->providers(ProviderInterface::TYPE_PREVIEW),
|
|
static fn(ProviderInterface $provider): bool => $provider instanceof PreviewProviderInterface,
|
|
);
|
|
}
|
|
}
|