* 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); } }