feat: add local system store provider
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
+3
-2
@@ -11,7 +11,8 @@ use KTXF\Module\ModuleBrowserInterface;
|
|||||||
use KTXF\Module\ModuleInstanceAbstract;
|
use KTXF\Module\ModuleInstanceAbstract;
|
||||||
use KTXF\Resource\Provider\ProviderInterface;
|
use KTXF\Resource\Provider\ProviderInterface;
|
||||||
use KTXM\ProviderLocalDocuments\Listeners\UserEventListener;
|
use KTXM\ProviderLocalDocuments\Listeners\UserEventListener;
|
||||||
use KTXM\ProviderLocalDocuments\Providers\Provider;
|
use KTXM\ProviderLocalDocuments\Providers\Provider as UserStoreProvider;
|
||||||
|
use KTXM\ProviderLocalDocuments\Providers\SystemStore\Provider as SystemStoreProvider;
|
||||||
|
|
||||||
class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
|
class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
|
||||||
{
|
{
|
||||||
@@ -69,7 +70,7 @@ class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
|
|||||||
$this->events->listen($this->handle(), UserCreatedEvent::class, UserEventListener::class, 'onUserCreated', DeliveryMode::Deferred);
|
$this->events->listen($this->handle(), UserCreatedEvent::class, UserEventListener::class, 'onUserCreated', DeliveryMode::Deferred);
|
||||||
$this->events->listen($this->handle(), UserDeletingEvent::class, UserEventListener::class, 'onUserDeleting', DeliveryMode::Deferred);
|
$this->events->listen($this->handle(), UserDeletingEvent::class, UserEventListener::class, 'onUserDeleting', DeliveryMode::Deferred);
|
||||||
// Register providers
|
// Register providers
|
||||||
$this->providerManager->register(ProviderInterface::TYPE_DOCUMENT, 'default', Provider::class);
|
$this->providerManager->register(ProviderInterface::TYPE_SYSTEM_STORE, 'local', SystemStoreProvider::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function registerBI(): array {
|
public function registerBI(): array {
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXM\ProviderLocalDocuments\Providers\SystemStore;
|
||||||
|
|
||||||
|
use DI\Attribute\Inject;
|
||||||
|
use KTXF\Resource\Provider\ResourceServiceBaseInterface;
|
||||||
|
use KTXF\Resource\SystemIdentity;
|
||||||
|
use KTXF\SystemStore\Provider\ProviderBaseInterface;
|
||||||
|
|
||||||
|
final class Provider implements ProviderBaseInterface
|
||||||
|
{
|
||||||
|
public const JSON_TYPE = ProviderBaseInterface::JSON_TYPE;
|
||||||
|
public const PROVIDER_IDENTIFIER = 'local';
|
||||||
|
private const SERVICE_IDENTIFIER = 'default';
|
||||||
|
|
||||||
|
private array $providerAbilities = [
|
||||||
|
self::CAPABILITY_SERVICE_LIST => true,
|
||||||
|
self::CAPABILITY_SERVICE_FETCH => true,
|
||||||
|
self::CAPABILITY_SERVICE_EXTANT => true,
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(#[Inject('rootDir')] private readonly string $rootDir)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function jsonSerialize(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
self::PROPERTY_TYPE => self::JSON_TYPE,
|
||||||
|
self::PROPERTY_IDENTIFIER => self::PROVIDER_IDENTIFIER,
|
||||||
|
self::PROPERTY_LABEL => $this->label(),
|
||||||
|
self::PROPERTY_CAPABILITIES => $this->providerAbilities,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function jsonDeserialize(array|string $data): static { return $this; }
|
||||||
|
public function type(): string { return self::TYPE_SYSTEM_STORE; }
|
||||||
|
public function identifier(): string { return self::PROVIDER_IDENTIFIER; }
|
||||||
|
public function label(): string { return 'Local System Store'; }
|
||||||
|
public function description(): string { return 'Stores tenant-owned internal blobs on local disk'; }
|
||||||
|
public function icon(): string { return 'hard-drive'; }
|
||||||
|
public function capable(string $value): bool { return !empty($this->providerAbilities[$value]); }
|
||||||
|
public function capabilities(): array { return $this->providerAbilities; }
|
||||||
|
|
||||||
|
public function serviceList(string $tenantId, string $userId, array $filter): array
|
||||||
|
{
|
||||||
|
if ($userId !== SystemIdentity::USER || ($filter !== [] && !in_array(self::SERVICE_IDENTIFIER, $filter, true))) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [self::SERVICE_IDENTIFIER => $this->service($tenantId)];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function serviceExtant(string $tenantId, string $userId, int|string ...$identifiers): array
|
||||||
|
{
|
||||||
|
$result = [];
|
||||||
|
foreach ($identifiers as $identifier) {
|
||||||
|
$result[$identifier] = $userId === SystemIdentity::USER && $identifier === self::SERVICE_IDENTIFIER;
|
||||||
|
}
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function serviceFetch(string $tenantId, string $userId, string|int $identifier): ?ResourceServiceBaseInterface
|
||||||
|
{
|
||||||
|
if ($userId !== SystemIdentity::USER || $identifier !== self::SERVICE_IDENTIFIER) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return $this->service($tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function service(string $tenantId): Service
|
||||||
|
{
|
||||||
|
return new Service($this->rootDir . '/storage/system-store/' . hash('sha256', $tenantId) . '/default');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXM\ProviderLocalDocuments\Providers\SystemStore;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use JsonException;
|
||||||
|
use KTXF\Resource\BinaryResource;
|
||||||
|
use KTXF\SystemStore\BlobInfo;
|
||||||
|
use KTXF\SystemStore\InvalidKeyException;
|
||||||
|
use KTXF\SystemStore\Service\SystemStoreServiceInterface;
|
||||||
|
use KTXF\SystemStore\SystemStoreException;
|
||||||
|
use KTXF\SystemStore\WriteCondition;
|
||||||
|
use KTXF\SystemStore\WriteConflictException;
|
||||||
|
|
||||||
|
final class Service implements SystemStoreServiceInterface
|
||||||
|
{
|
||||||
|
public function __construct(private readonly string $root)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function jsonSerialize(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
self::PROPERTY_TYPE => self::JSON_TYPE,
|
||||||
|
self::PROPERTY_PROVIDER => 'local',
|
||||||
|
self::PROPERTY_IDENTIFIER => 'default',
|
||||||
|
self::PROPERTY_LABEL => 'Local System Store',
|
||||||
|
self::PROPERTY_ENABLED => true,
|
||||||
|
self::PROPERTY_CAPABILITIES => [],
|
||||||
|
self::PROPERTY_AUXILIARY => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function jsonDeserialize(array|string $data): static { return $this; }
|
||||||
|
public function capable(string $value): bool { return false; }
|
||||||
|
public function capabilities(): array { return []; }
|
||||||
|
public function provider(): string { return 'local'; }
|
||||||
|
public function identifier(): string { return 'default'; }
|
||||||
|
public function getLabel(): string { return 'Local System Store'; }
|
||||||
|
public function getEnabled(): bool { return true; }
|
||||||
|
public function getLocation(): null { return null; }
|
||||||
|
public function getIdentity(): null { return null; }
|
||||||
|
public function getAuxiliary(): array { return []; }
|
||||||
|
|
||||||
|
public function stat(string $key): ?BlobInfo
|
||||||
|
{
|
||||||
|
$key = $this->validateKey($key);
|
||||||
|
$lock = $this->lock($key, LOCK_SH);
|
||||||
|
try {
|
||||||
|
return $this->statUnlocked($key);
|
||||||
|
} finally {
|
||||||
|
$this->unlock($lock);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function read(string $key): ?BinaryResource
|
||||||
|
{
|
||||||
|
$key = $this->validateKey($key);
|
||||||
|
$lock = $this->lock($key, LOCK_SH);
|
||||||
|
try {
|
||||||
|
$info = $this->statUnlocked($key);
|
||||||
|
if ($info === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$handle = fopen($this->dataPath($key), 'rb');
|
||||||
|
if ($handle === false) {
|
||||||
|
throw new SystemStoreException("Unable to open local blob '{$key}' for reading");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
$this->unlock($lock);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stream = (function () use ($handle): \Generator {
|
||||||
|
try {
|
||||||
|
while (!feof($handle)) {
|
||||||
|
$chunk = fread($handle, 8192);
|
||||||
|
if ($chunk === false) {
|
||||||
|
throw new SystemStoreException('Unable to read local system-store blob');
|
||||||
|
}
|
||||||
|
if ($chunk !== '') {
|
||||||
|
yield $chunk;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
fclose($handle);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return new BinaryResource(basename($key), $info->mimeType, $stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function write(
|
||||||
|
string $key,
|
||||||
|
BinaryResource $content,
|
||||||
|
array $metadata = [],
|
||||||
|
?WriteCondition $condition = null,
|
||||||
|
): BlobInfo {
|
||||||
|
$key = $this->validateKey($key);
|
||||||
|
if ($content->mimeType() === '') {
|
||||||
|
throw new \InvalidArgumentException('Local system-store blob MIME type cannot be empty');
|
||||||
|
}
|
||||||
|
$lock = $this->lock($key, LOCK_EX);
|
||||||
|
$dataTemp = null;
|
||||||
|
$metaTemp = null;
|
||||||
|
try {
|
||||||
|
$this->assertCondition($this->statUnlocked($key), $condition);
|
||||||
|
$dataPath = $this->dataPath($key);
|
||||||
|
$metaPath = $this->metaPath($key);
|
||||||
|
$this->ensureParent($dataPath, $this->dataRoot());
|
||||||
|
$this->ensureParent($metaPath, $this->metaRoot());
|
||||||
|
|
||||||
|
$dataTemp = tempnam(dirname($dataPath), '.write-');
|
||||||
|
if ($dataTemp === false || ($handle = fopen($dataTemp, 'wb')) === false) {
|
||||||
|
throw new SystemStoreException('Unable to create a temporary local blob');
|
||||||
|
}
|
||||||
|
$hash = hash_init('sha256');
|
||||||
|
$size = 0;
|
||||||
|
try {
|
||||||
|
foreach ($content->stream() as $chunk) {
|
||||||
|
if (!is_string($chunk)) {
|
||||||
|
throw new SystemStoreException('BinaryResource streams must yield strings');
|
||||||
|
}
|
||||||
|
$this->writeAll($handle, $chunk);
|
||||||
|
hash_update($hash, $chunk);
|
||||||
|
$size += strlen($chunk);
|
||||||
|
}
|
||||||
|
if (!fflush($handle) || (function_exists('fsync') && !fsync($handle))) {
|
||||||
|
throw new SystemStoreException('Unable to finalize a temporary local blob');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
fclose($handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
$etag = 'sha256:' . hash_final($hash);
|
||||||
|
$metaTemp = tempnam(dirname($metaPath), '.write-');
|
||||||
|
if ($metaTemp === false) {
|
||||||
|
throw new SystemStoreException('Unable to create temporary local blob metadata');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$encoded = json_encode([
|
||||||
|
'mimeType' => $content->mimeType(),
|
||||||
|
'etag' => $etag,
|
||||||
|
'attributes' => $metadata,
|
||||||
|
], JSON_THROW_ON_ERROR);
|
||||||
|
} catch (JsonException $error) {
|
||||||
|
throw new SystemStoreException('Local blob metadata is not JSON serializable', previous: $error);
|
||||||
|
}
|
||||||
|
if (file_put_contents($metaTemp, $encoded, LOCK_EX) === false) {
|
||||||
|
throw new SystemStoreException('Unable to write temporary local blob metadata');
|
||||||
|
}
|
||||||
|
if (!rename($dataTemp, $dataPath)) {
|
||||||
|
throw new SystemStoreException("Unable to publish local blob '{$key}'");
|
||||||
|
}
|
||||||
|
$dataTemp = null;
|
||||||
|
if (!rename($metaTemp, $metaPath)) {
|
||||||
|
throw new SystemStoreException("Unable to publish metadata for local blob '{$key}'");
|
||||||
|
}
|
||||||
|
$metaTemp = null;
|
||||||
|
clearstatcache(true, $dataPath);
|
||||||
|
$modified = filemtime($dataPath);
|
||||||
|
if ($modified === false) {
|
||||||
|
throw new SystemStoreException("Unable to inspect local blob '{$key}' after writing");
|
||||||
|
}
|
||||||
|
return new BlobInfo(
|
||||||
|
$key,
|
||||||
|
$content->mimeType(),
|
||||||
|
$size,
|
||||||
|
$etag,
|
||||||
|
new DateTimeImmutable('@' . (string) $modified),
|
||||||
|
$metadata,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if ($dataTemp !== null && is_file($dataTemp)) { unlink($dataTemp); }
|
||||||
|
if ($metaTemp !== null && is_file($metaTemp)) { unlink($metaTemp); }
|
||||||
|
$this->unlock($lock);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(string $key, ?WriteCondition $condition = null): bool
|
||||||
|
{
|
||||||
|
$key = $this->validateKey($key);
|
||||||
|
$lock = $this->lock($key, LOCK_EX);
|
||||||
|
try {
|
||||||
|
$current = $this->statUnlocked($key);
|
||||||
|
$this->assertCondition($current, $condition);
|
||||||
|
if ($current === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!unlink($this->dataPath($key))) {
|
||||||
|
throw new SystemStoreException("Unable to delete local blob '{$key}'");
|
||||||
|
}
|
||||||
|
$metaPath = $this->metaPath($key);
|
||||||
|
if (is_file($metaPath) && !unlink($metaPath)) {
|
||||||
|
throw new SystemStoreException("Unable to delete metadata for local blob '{$key}'");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} finally {
|
||||||
|
$this->unlock($lock);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function list(string $prefix = ''): iterable
|
||||||
|
{
|
||||||
|
if ($prefix !== '') {
|
||||||
|
$prefix = $this->validateKey($prefix, true);
|
||||||
|
}
|
||||||
|
$root = $this->dataRoot();
|
||||||
|
if (!is_dir($root)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS));
|
||||||
|
foreach ($files as $file) {
|
||||||
|
if (!$file->isFile() || $file->isLink()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$key = str_replace(DIRECTORY_SEPARATOR, '/', substr($file->getPathname(), strlen($root) + 1));
|
||||||
|
if ($prefix !== '' && !str_starts_with($key, $prefix)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$info = $this->stat($key);
|
||||||
|
if ($info !== null) {
|
||||||
|
yield $info;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function statUnlocked(string $key): ?BlobInfo
|
||||||
|
{
|
||||||
|
$path = $this->dataPath($key);
|
||||||
|
if (!is_file($path) || is_link($path)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$this->assertExistingPathInside($path, $this->dataRoot());
|
||||||
|
$size = filesize($path);
|
||||||
|
$modified = filemtime($path);
|
||||||
|
$hash = hash_file('sha256', $path);
|
||||||
|
if ($size === false || $modified === false || $hash === false) {
|
||||||
|
throw new SystemStoreException("Unable to inspect local blob '{$key}'");
|
||||||
|
}
|
||||||
|
$etag = 'sha256:' . $hash;
|
||||||
|
$stored = $this->readMetadata($key);
|
||||||
|
$trusted = $stored['etag'] === $etag;
|
||||||
|
return new BlobInfo(
|
||||||
|
$key,
|
||||||
|
$trusted ? $stored['mimeType'] : 'application/octet-stream',
|
||||||
|
$size,
|
||||||
|
$etag,
|
||||||
|
new DateTimeImmutable('@' . (string) $modified),
|
||||||
|
$trusted ? $stored['attributes'] : [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{mimeType: string, etag: string, attributes: array} */
|
||||||
|
private function readMetadata(string $key): array
|
||||||
|
{
|
||||||
|
$path = $this->metaPath($key);
|
||||||
|
if (!is_file($path) || is_link($path)) {
|
||||||
|
return ['mimeType' => 'application/octet-stream', 'etag' => '', 'attributes' => []];
|
||||||
|
}
|
||||||
|
$decoded = json_decode((string) file_get_contents($path), true);
|
||||||
|
if (!is_array($decoded)) {
|
||||||
|
return ['mimeType' => 'application/octet-stream', 'etag' => '', 'attributes' => []];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'mimeType' => is_string($decoded['mimeType'] ?? null) ? $decoded['mimeType'] : 'application/octet-stream',
|
||||||
|
'etag' => is_string($decoded['etag'] ?? null) ? $decoded['etag'] : '',
|
||||||
|
'attributes' => is_array($decoded['attributes'] ?? null) ? $decoded['attributes'] : [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertCondition(?BlobInfo $current, ?WriteCondition $condition): void
|
||||||
|
{
|
||||||
|
if ($condition === null || $condition->mode === WriteCondition::NONE) { return; }
|
||||||
|
if ($condition->mode === WriteCondition::IF_ABSENT && $current !== null) {
|
||||||
|
throw new WriteConflictException('The local blob already exists');
|
||||||
|
}
|
||||||
|
if ($condition->mode === WriteCondition::IF_MATCH && ($current === null || $current->etag !== $condition->etag)) {
|
||||||
|
throw new WriteConflictException('The local blob ETag does not match');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return resource */
|
||||||
|
private function lock(string $key, int $operation)
|
||||||
|
{
|
||||||
|
$root = $this->root . '/locks';
|
||||||
|
$this->ensureDirectory($root);
|
||||||
|
$this->assertExistingPathInside($root, $this->root);
|
||||||
|
$handle = fopen($root . '/' . hash('sha256', $key) . '.lock', 'c');
|
||||||
|
if ($handle === false || !flock($handle, $operation)) {
|
||||||
|
if (is_resource($handle)) { fclose($handle); }
|
||||||
|
throw new SystemStoreException("Unable to lock local blob '{$key}'");
|
||||||
|
}
|
||||||
|
return $handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param resource $lock */
|
||||||
|
private function unlock($lock): void
|
||||||
|
{
|
||||||
|
flock($lock, LOCK_UN);
|
||||||
|
fclose($lock);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param resource $handle */
|
||||||
|
private function writeAll($handle, string $content): void
|
||||||
|
{
|
||||||
|
for ($offset = 0, $length = strlen($content); $offset < $length; $offset += $written) {
|
||||||
|
$written = fwrite($handle, substr($content, $offset));
|
||||||
|
if ($written === false || $written === 0) {
|
||||||
|
throw new SystemStoreException('Unable to write a temporary local blob');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validateKey(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('Local system-store keys must be 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('Local system-store keys cannot contain empty or traversal segments');
|
||||||
|
}
|
||||||
|
return $key;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function dataRoot(): string { return $this->root . '/data'; }
|
||||||
|
private function metaRoot(): string { return $this->root . '/meta'; }
|
||||||
|
private function dataPath(string $key): string { return $this->dataRoot() . '/' . str_replace('/', DIRECTORY_SEPARATOR, $key); }
|
||||||
|
private function metaPath(string $key): string { return $this->metaRoot() . '/' . str_replace('/', DIRECTORY_SEPARATOR, $key) . '.json'; }
|
||||||
|
|
||||||
|
private function ensureParent(string $path, string $root): void
|
||||||
|
{
|
||||||
|
$this->ensureDirectory(dirname($path));
|
||||||
|
$this->assertExistingPathInside(dirname($path), $root);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureDirectory(string $path): void
|
||||||
|
{
|
||||||
|
if (!is_dir($path) && !mkdir($path, 0750, true) && !is_dir($path)) {
|
||||||
|
throw new SystemStoreException("Unable to create local system-store directory '{$path}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertExistingPathInside(string $path, string $root): void
|
||||||
|
{
|
||||||
|
$resolvedPath = realpath($path);
|
||||||
|
$resolvedRoot = realpath($root);
|
||||||
|
if ($resolvedPath === false || $resolvedRoot === false || ($resolvedPath !== $resolvedRoot && !str_starts_with($resolvedPath, $resolvedRoot . DIRECTORY_SEPARATOR))) {
|
||||||
|
throw new SystemStoreException('Local system-store path escaped its configured root');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXT\ProviderLocalDocuments\Tests\Unit;
|
||||||
|
|
||||||
|
use KTXC\Resource\ProviderManager;
|
||||||
|
use KTXF\Event\EventListenerRegistrarInterface;
|
||||||
|
use KTXF\Resource\BinaryResource;
|
||||||
|
use KTXF\Resource\Provider\ProviderInterface;
|
||||||
|
use KTXF\Resource\SystemIdentity;
|
||||||
|
use KTXF\SystemStore\InvalidKeyException;
|
||||||
|
use KTXF\SystemStore\WriteCondition;
|
||||||
|
use KTXF\SystemStore\WriteConflictException;
|
||||||
|
use KTXM\ProviderLocalDocuments\Module;
|
||||||
|
use KTXM\ProviderLocalDocuments\Providers\Provider as DocumentProvider;
|
||||||
|
use KTXM\ProviderLocalDocuments\Providers\SystemStore\Provider;
|
||||||
|
use KTXM\ProviderLocalDocuments\Providers\SystemStore\Service;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class SystemStoreServiceTest extends TestCase
|
||||||
|
{
|
||||||
|
private string $root;
|
||||||
|
private Service $service;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->root = sys_get_temp_dir() . '/ktrix-local-system-store-' . bin2hex(random_bytes(8));
|
||||||
|
$this->service = new Service($this->root . '/service');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
if (!is_dir($this->root)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$items = new \RecursiveIteratorIterator(
|
||||||
|
new \RecursiveDirectoryIterator($this->root, \FilesystemIterator::SKIP_DOTS),
|
||||||
|
\RecursiveIteratorIterator::CHILD_FIRST,
|
||||||
|
);
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$item->isDir() && !$item->isLink() ? rmdir($item->getPathname()) : unlink($item->getPathname());
|
||||||
|
}
|
||||||
|
rmdir($this->root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function writesStatsAndStreamsABlob(): void
|
||||||
|
{
|
||||||
|
$written = $this->service->write(
|
||||||
|
'previews/report.webp',
|
||||||
|
$this->resource('preview-data'),
|
||||||
|
['variant' => 'popover'],
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame('previews/report.webp', $written->key);
|
||||||
|
self::assertSame(strlen('preview-data'), $written->size);
|
||||||
|
self::assertStringStartsWith('sha256:', $written->etag);
|
||||||
|
self::assertSame($written->etag, $this->service->stat('previews/report.webp')?->etag);
|
||||||
|
self::assertSame(['variant' => 'popover'], $this->service->stat('previews/report.webp')?->attributes);
|
||||||
|
|
||||||
|
$read = $this->service->read('previews/report.webp');
|
||||||
|
self::assertNotNull($read);
|
||||||
|
self::assertSame('image/webp', $read->mimeType());
|
||||||
|
self::assertSame('preview-data', implode('', iterator_to_array($read->stream())));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function enforcesAtomicWriteConditions(): void
|
||||||
|
{
|
||||||
|
$first = $this->service->write('preview.webp', $this->resource('first'));
|
||||||
|
$second = $this->service->write(
|
||||||
|
'preview.webp',
|
||||||
|
$this->resource('second'),
|
||||||
|
condition: WriteCondition::ifMatch($first->etag),
|
||||||
|
);
|
||||||
|
self::assertNotSame($first->etag, $second->etag);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->service->write(
|
||||||
|
'preview.webp',
|
||||||
|
$this->resource('stale'),
|
||||||
|
condition: WriteCondition::ifMatch($first->etag),
|
||||||
|
);
|
||||||
|
self::fail('A stale ETag should fail');
|
||||||
|
} catch (WriteConflictException) {
|
||||||
|
self::assertSame($second->etag, $this->service->stat('preview.webp')?->etag);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->expectException(WriteConflictException::class);
|
||||||
|
$this->service->write(
|
||||||
|
'preview.webp',
|
||||||
|
$this->resource('duplicate'),
|
||||||
|
condition: WriteCondition::ifAbsent(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function deletesConditionally(): void
|
||||||
|
{
|
||||||
|
$info = $this->service->write('preview.webp', $this->resource('content'));
|
||||||
|
|
||||||
|
self::assertTrue($this->service->delete('preview.webp', WriteCondition::ifMatch($info->etag)));
|
||||||
|
self::assertNull($this->service->stat('preview.webp'));
|
||||||
|
self::assertFalse($this->service->delete('preview.webp'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function lazilyListsOnlyTheRequestedPrefix(): void
|
||||||
|
{
|
||||||
|
$this->service->write('previews/a.webp', $this->resource('a'));
|
||||||
|
$this->service->write('previews/nested/b.webp', $this->resource('b'));
|
||||||
|
$this->service->write('icons/c.webp', $this->resource('c'));
|
||||||
|
|
||||||
|
$items = iterator_to_array($this->service->list('previews/'));
|
||||||
|
$keys = array_map(static fn($item): string => $item->key, $items);
|
||||||
|
sort($keys);
|
||||||
|
|
||||||
|
self::assertSame(['previews/a.webp', 'previews/nested/b.webp'], $keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function rejectsTraversalKeys(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidKeyException::class);
|
||||||
|
$this->service->read('../secret');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function providerExposesTenantIsolatedServicesOnlyToSystem(): void
|
||||||
|
{
|
||||||
|
$provider = new Provider($this->root);
|
||||||
|
|
||||||
|
self::assertNull($provider->serviceFetch('tenant-a', 'user-a', 'default'));
|
||||||
|
self::assertSame([], $provider->serviceList('tenant-a', 'user-a', []));
|
||||||
|
$tenantA = $provider->serviceFetch('tenant-a', SystemIdentity::USER, 'default');
|
||||||
|
$tenantB = $provider->serviceFetch('tenant-b', SystemIdentity::USER, 'default');
|
||||||
|
self::assertInstanceOf(Service::class, $tenantA);
|
||||||
|
self::assertInstanceOf(Service::class, $tenantB);
|
||||||
|
|
||||||
|
$tenantA->write('same-key', $this->resource('tenant-a'));
|
||||||
|
$tenantB->write('same-key', $this->resource('tenant-b'));
|
||||||
|
|
||||||
|
self::assertSame('tenant-a', implode('', iterator_to_array($tenantA->read('same-key')->stream())));
|
||||||
|
self::assertSame('tenant-b', implode('', iterator_to_array($tenantB->read('same-key')->stream())));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function moduleRegistersTheLocalSystemStoreProvider(): void
|
||||||
|
{
|
||||||
|
$registrations = [];
|
||||||
|
$providers = $this->createMock(ProviderManager::class);
|
||||||
|
$providers->expects(self::exactly(2))
|
||||||
|
->method('register')
|
||||||
|
->willReturnCallback(function (string $type, string $identifier, string $class) use (&$registrations): void {
|
||||||
|
$registrations[] = [$type, $identifier, $class];
|
||||||
|
});
|
||||||
|
$events = $this->createStub(EventListenerRegistrarInterface::class);
|
||||||
|
|
||||||
|
(new Module($providers, $events))->boot();
|
||||||
|
|
||||||
|
self::assertContains(
|
||||||
|
[ProviderInterface::TYPE_DOCUMENT, 'default', DocumentProvider::class],
|
||||||
|
$registrations,
|
||||||
|
);
|
||||||
|
self::assertContains(
|
||||||
|
[ProviderInterface::TYPE_SYSTEM_STORE, 'local', Provider::class],
|
||||||
|
$registrations,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resource(string $content): BinaryResource
|
||||||
|
{
|
||||||
|
return new BinaryResource(
|
||||||
|
'preview.webp',
|
||||||
|
'image/webp',
|
||||||
|
(function () use ($content): \Generator {
|
||||||
|
yield substr($content, 0, 3);
|
||||||
|
yield substr($content, 3);
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user