357 lines
8.6 KiB
PHP
357 lines
8.6 KiB
PHP
<?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);
|
|
}
|
|
|
|
}
|