Files
server/core/lib/Preview/PreviewManager.php
T
Sebastian 4dd09cc7ec feat: add background to pages
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-09-14 18:46:36 -04:00

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