Initial commit

This commit is contained in:
root
2025-12-21 09:57:51 -05:00
committed by Sebastian Krupinski
commit c0fa9cadfb
10 changed files with 2778 additions and 0 deletions

356
lib/Store/BlobStore.php Normal file
View File

@@ -0,0 +1,356 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXM\FileProviderLocal\Store;
use RuntimeException;
/**
* BlobStore - Handles file read/write operations on the local filesystem
*
* Files are stored using a UUID-based sharded folder structure where the first
* N characters (defined by SHARD_PREFIX_LENGTH) of the entity ID are used as
* a subdirectory to distribute files and prevent filesystem performance issues.
*/
class BlobStore {
private const BLOB_EXTENSION = '.blob';
private const META_EXTENSION = '.meta';
private const SHARD_PREFIX_LENGTH = 2;
private ?string $rootPath = null;
private int $defaultFolderPermissions = 0755;
/**
* Configure the root path for file storage
*
* @param string $rootPath Base path for all file operations
* @return self
* @throws RuntimeException If root path doesn't exist and can't be created
*/
public function configureRoot(string $rootPath): self {
$this->rootPath = rtrim($rootPath, DIRECTORY_SEPARATOR);
if (!is_dir($this->rootPath)) {
if (!mkdir($this->rootPath, $this->defaultFolderPermissions, true)) {
throw new RuntimeException("Failed to create root path: {$this->rootPath}");
}
}
return $this;
}
/**
* Generate the absolute path for an entity's blob file
* Uses first 4 characters of ID as subdirectory for distribution
*
* @param string $id The entity ID (UUID)
* @return string Absolute file path with .blob extension
* @throws RuntimeException If root path not configured
*/
protected function blobPathFromId(string $id): string {
if ($this->rootPath === null) {
throw new RuntimeException("BlobStore root path not configured. Call configureRoot() first.");
}
$prefix = substr($id, 0, self::SHARD_PREFIX_LENGTH);
return $this->rootPath . DIRECTORY_SEPARATOR . $prefix . DIRECTORY_SEPARATOR . $id . self::BLOB_EXTENSION;
}
/**
* Generate the absolute path for an entity's meta file
* Uses first 4 characters of ID as subdirectory for distribution
*
* @param string $id The entity ID (UUID)
* @return string Absolute file path with .meta extension
* @throws RuntimeException If root path not configured
*/
protected function metaPathFromId(string $id): string {
if ($this->rootPath === null) {
throw new RuntimeException("BlobStore root path not configured. Call configureRoot() first.");
}
$prefix = substr($id, 0, self::SHARD_PREFIX_LENGTH);
return $this->rootPath . DIRECTORY_SEPARATOR . $prefix . DIRECTORY_SEPARATOR . $id . self::META_EXTENSION;
}
/**
* Ensure parent directory exists for a path
*/
protected function ensureDirectory(string $absPath): bool {
$parentDir = dirname($absPath);
if (!is_dir($parentDir)) {
return mkdir($parentDir, $this->defaultFolderPermissions, true);
}
return true;
}
// ========== Blob Read Operations ==========
/**
* Read entire file contents by entity ID
*
* @param string $id Entity ID
* @return string|null File contents or null if not found
*/
public function blobRead(string $id): ?string {
$absPath = $this->blobPathFromId($id);
if (!is_file($absPath)) {
return null;
}
$content = file_get_contents($absPath);
return $content !== false ? $content : null;
}
/**
* Read file chunk by entity ID
*
* @param string $id Entity ID
* @param int $offset Start position
* @param int $length Number of bytes to read
* @return string|null Chunk contents or null if not found
*/
public function blobReadChunk(string $id, int $offset, int $length): ?string {
$absPath = $this->blobPathFromId($id);
if (!is_file($absPath)) {
return null;
}
$handle = fopen($absPath, 'rb');
if ($handle === false) {
return null;
}
if ($offset > 0) {
fseek($handle, $offset);
}
$content = fread($handle, $length);
fclose($handle);
return $content !== false ? $content : null;
}
/**
* Open read stream by entity ID
*
* @param string $id Entity ID
* @return resource|null File handle or null if not found
*/
public function blobReadStream(string $id) {
$absPath = $this->blobPathFromId($id);
if (!is_file($absPath)) {
return null;
}
$handle = fopen($absPath, 'rb');
return $handle !== false ? $handle : null;
}
/**
* Get file size by entity ID
*
* @param string $id Entity ID
* @return int|null Size in bytes or null if not found
*/
public function blobSize(string $id): ?int {
$absPath = $this->blobPathFromId($id);
if (!is_file($absPath)) {
return null;
}
$size = filesize($absPath);
return $size !== false ? $size : null;
}
// ========== Blob Write Operations ==========
/**
* Write content to file by entity ID
*
* @param string $id Entity ID
* @param string $content Content to write
* @return int|null Number of bytes written or null on failure
*/
public function blobWrite(string $id, string $content): ?int {
$absPath = $this->blobPathFromId($id);
if (!$this->ensureDirectory($absPath)) {
return null;
}
$bytes = file_put_contents($absPath, $content);
return $bytes !== false ? $bytes : null;
}
/**
* Write content at specific position by entity ID
*
* @param string $id Entity ID
* @param int $offset Position to write at
* @param string $content Content to write
* @return int|null Number of bytes written or null on failure
*/
public function blobWriteChunk(string $id, int $offset, string $content): ?int {
$absPath = $this->blobPathFromId($id);
if (!$this->ensureDirectory($absPath)) {
return null;
}
// Create file if it doesn't exist
if (!is_file($absPath)) {
touch($absPath);
}
$handle = fopen($absPath, 'r+b');
if ($handle === false) {
return null;
}
fseek($handle, $offset);
$bytes = fwrite($handle, $content);
fclose($handle);
return $bytes !== false ? $bytes : null;
}
/**
* Open write stream by entity ID
*
* @param string $id Entity ID
* @return resource|null File handle or null on failure
*/
public function blobWriteStream(string $id) {
$absPath = $this->blobPathFromId($id);
if (!$this->ensureDirectory($absPath)) {
return null;
}
$handle = fopen($absPath, 'wb');
return $handle !== false ? $handle : null;
}
// ========== Blob Delete Operations ==========
/**
* Delete file by entity ID (both blob and meta files)
*
* @param string $id Entity ID
*
* @return bool Success (true if deleted or didn't exist)
*/
public function blobDelete(string $id): bool {
$blobPath = $this->blobPathFromId($id);
$metaPath = $this->metaPathFromId($id);
$blobDeleted = true;
$metaDeleted = true;
if (is_file($blobPath)) {
$blobDeleted = unlink($blobPath);
}
if (is_file($metaPath)) {
$metaDeleted = unlink($metaPath);
}
return $blobDeleted && $metaDeleted;
}
// ========== Blob Existence Check ==========
/**
* Check if blob file exists by entity ID
*
* @param string $id Entity ID
* @return bool
*/
public function blobExtant(string $id): bool {
return is_file($this->blobPathFromId($id));
}
// ========== Meta File Operations ==========
/**
* Write metadata to file by entity ID
*
* @param string $id Entity ID
* @param array $metadata Metadata array to store
* @return bool Success
*/
public function metaWrite(string $id, array $metadata): bool {
$absPath = $this->metaPathFromId($id);
if (!$this->ensureDirectory($absPath)) {
return false;
}
$json = json_encode($metadata, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($json === false) {
return false;
}
$bytes = file_put_contents($absPath, $json);
return $bytes !== false;
}
/**
* Read metadata from file by entity ID
*
* @param string $id Entity ID
* @return array|null Metadata array or null if not found
*/
public function metaRead(string $id): ?array {
$absPath = $this->metaPathFromId($id);
if (!is_file($absPath)) {
return null;
}
$content = file_get_contents($absPath);
if ($content === false) {
return null;
}
$metadata = json_decode($content, true);
return is_array($metadata) ? $metadata : null;
}
/**
* Check if meta file exists by entity ID
*
* @param string $id Entity ID
* @return bool
*/
public function metaExtant(string $id): bool {
return is_file($this->metaPathFromId($id));
}
/**
* Delete only the meta file by entity ID
*
* @param string $id Entity ID
* @return bool Success (true if deleted or didn't exist)
*/
public function metaDelete(string $id): bool {
$absPath = $this->metaPathFromId($id);
if (!is_file($absPath)) {
return true;
}
return unlink($absPath);
}
}