feat: add local system store provider
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user