7832527511
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
362 lines
14 KiB
PHP
362 lines
14 KiB
PHP
<?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
|
|
{
|
|
private const BLOB_EXTENSION = '.blob';
|
|
private const META_EXTENSION = '.meta';
|
|
private const LOCK_EXTENSION = '.lock';
|
|
|
|
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->blobPath($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->blobPath($key);
|
|
$metaPath = $this->metaPath($key);
|
|
$this->ensureParent($dataPath);
|
|
|
|
$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->blobPath($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->root;
|
|
if (!is_dir($root)) {
|
|
return;
|
|
}
|
|
$files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS));
|
|
foreach ($files as $file) {
|
|
if (!$file->isFile() || $file->isLink()) {
|
|
continue;
|
|
}
|
|
$pathname = $file->getPathname();
|
|
if (!str_ends_with($pathname, self::BLOB_EXTENSION)) {
|
|
continue;
|
|
}
|
|
$relative = substr($pathname, strlen($root) + 1, -strlen(self::BLOB_EXTENSION));
|
|
$key = str_replace(DIRECTORY_SEPARATOR, '/', $relative);
|
|
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->blobPath($key);
|
|
if (!is_file($path) || is_link($path)) {
|
|
return null;
|
|
}
|
|
$this->assertExistingPathInside($path, $this->root);
|
|
$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)
|
|
{
|
|
$path = $this->lockPath($key);
|
|
$this->ensureDirectory(dirname($path));
|
|
$this->assertExistingPathInside(dirname($path), $this->root);
|
|
$handle = fopen($path, '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 blobPath(string $key): string { return $this->root . '/' . str_replace('/', DIRECTORY_SEPARATOR, $key) . self::BLOB_EXTENSION; }
|
|
private function metaPath(string $key): string { return $this->root . '/' . str_replace('/', DIRECTORY_SEPARATOR, $key) . self::META_EXTENSION; }
|
|
private function lockPath(string $key): string { return $this->root . '/' . str_replace('/', DIRECTORY_SEPARATOR, $key) . self::LOCK_EXTENSION; }
|
|
|
|
private function ensureParent(string $path): void
|
|
{
|
|
$this->ensureDirectory(dirname($path));
|
|
$this->assertExistingPathInside(dirname($path), $this->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');
|
|
}
|
|
}
|
|
}
|