Files
server/core/lib/SystemStore/SystemStoreManager.php
T
2026-08-28 20:47:27 -04:00

168 lines
5.7 KiB
PHP

<?php
declare(strict_types=1);
namespace KTXC\SystemStore;
use KTXC\Resource\ProviderManager;
use KTXC\Service\TenantService;
use KTXF\Resource\BinaryResource;
use KTXF\Resource\Provider\ProviderInterface;
use KTXF\Resource\SystemIdentity;
use KTXF\SystemStore\BlobInfo;
use KTXF\SystemStore\InvalidKeyException;
use KTXF\SystemStore\Provider\ProviderBaseInterface;
use KTXF\SystemStore\Service\SystemStoreServiceInterface;
use KTXF\SystemStore\StoreReference;
use KTXF\SystemStore\SystemStoreException;
use KTXF\SystemStore\SystemStoreManagerInterface;
use KTXF\SystemStore\WriteCondition;
final readonly class SystemStoreManager implements SystemStoreManagerInterface
{
public function __construct(
private TenantService $tenants,
private ProviderManager $providers,
) {
}
public function stat(string $tenantId, string $store, string $key): ?BlobInfo
{
[$service, $reference] = $this->resolve($tenantId, $store);
$info = $service->stat($this->qualify($reference, $key));
return $info === null ? null : $this->logicalInfo($info, $reference);
}
public function read(string $tenantId, string $store, string $key): ?BinaryResource
{
[$service, $reference] = $this->resolve($tenantId, $store);
return $service->read($this->qualify($reference, $key));
}
public function write(
string $tenantId,
string $store,
string $key,
BinaryResource $content,
array $metadata = [],
?WriteCondition $condition = null,
): BlobInfo {
[$service, $reference] = $this->resolve($tenantId, $store);
$info = $service->write(
$this->qualify($reference, $key),
$content,
$metadata,
$condition,
);
return $this->logicalInfo($info, $reference);
}
public function delete(
string $tenantId,
string $store,
string $key,
?WriteCondition $condition = null,
): bool {
[$service, $reference] = $this->resolve($tenantId, $store);
return $service->delete($this->qualify($reference, $key), $condition);
}
public function list(string $tenantId, string $store, string $prefix = ''): iterable
{
[$service, $reference] = $this->resolve($tenantId, $store);
$physicalPrefix = $reference->namespace . '/';
if ($prefix !== '') {
$physicalPrefix .= $this->normalizeKey($prefix, true);
}
return $this->logicalItems($service->list($physicalPrefix), $reference);
}
/** @return array{SystemStoreServiceInterface, StoreReference} */
private function resolve(string $tenantId, string $store): array
{
$tenant = $this->tenants->fetchById($tenantId);
if ($tenant === null) {
throw new SystemStoreException("Tenant '{$tenantId}' was not found");
}
$reference = $tenant->getConfiguration()->stores()->store($store);
if ($reference === null) {
throw new SystemStoreException("System store '{$store}' is not configured for tenant '{$tenantId}'");
}
$provider = $this->providers->resolve(ProviderInterface::TYPE_SYSTEM_STORE, $reference->provider);
if (!$provider instanceof ProviderBaseInterface) {
throw new SystemStoreException("System-store provider '{$reference->provider}' is unavailable or incompatible");
}
$service = $provider->serviceFetch($tenantId, SystemIdentity::USER, $reference->service);
if (!$service instanceof SystemStoreServiceInterface) {
throw new SystemStoreException("System-store service '{$reference->service}' is unavailable or incompatible");
}
return [$service, $reference];
}
private function qualify(StoreReference $reference, string $key): string
{
return $reference->namespace . '/' . $this->normalizeKey($key);
}
private function normalizeKey(string $key, bool $allowTrailingSlash = false): string
{
if (
$key === ''
|| str_starts_with($key, '/')
|| (!$allowTrailingSlash && str_ends_with($key, '/'))
|| str_contains($key, '\\')
|| str_contains($key, "\0")
) {
throw new InvalidKeyException('System-store keys must be non-empty normalized relative keys');
}
$segments = explode('/', $key);
if ($allowTrailingSlash && end($segments) === '') {
array_pop($segments);
}
if (in_array('', $segments, true) || in_array('.', $segments, true) || in_array('..', $segments, true)) {
throw new InvalidKeyException('System-store keys cannot contain empty or traversal segments');
}
return $key;
}
private function logicalInfo(BlobInfo $info, StoreReference $reference): BlobInfo
{
$prefix = $reference->namespace . '/';
if (!str_starts_with($info->key, $prefix)) {
throw new SystemStoreException('System-store provider returned a blob outside the configured namespace');
}
return new BlobInfo(
key: substr($info->key, strlen($prefix)),
mimeType: $info->mimeType,
size: $info->size,
etag: $info->etag,
modifiedAt: $info->modifiedAt,
attributes: $info->attributes,
);
}
/**
* @param iterable<BlobInfo> $items
* @return iterable<BlobInfo>
*/
private function logicalItems(iterable $items, StoreReference $reference): iterable
{
foreach ($items as $item) {
if (!$item instanceof BlobInfo) {
throw new SystemStoreException('System-store provider returned invalid listing metadata');
}
yield $this->logicalInfo($item, $reference);
}
}
}