413 lines
10 KiB
PHP
413 lines
10 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace KTXF\Cache\Store;
|
|
|
|
use KTXF\Cache\BlobCacheInterface;
|
|
use KTXF\Cache\CacheScope;
|
|
|
|
/**
|
|
* File-based Blob Cache Implementation
|
|
*
|
|
* Stores binary/media data with metadata in the storage directory.
|
|
* Directory structure: storage/{tid}/cache/{usage}/ or storage/{tid}/{uid}/cache/{usage}/
|
|
*/
|
|
class FileBlobCache implements BlobCacheInterface
|
|
{
|
|
private string $basePath;
|
|
private ?string $tenantId = null;
|
|
private ?string $userId = null;
|
|
|
|
public function __construct(string $projectDir)
|
|
{
|
|
$this->basePath = rtrim($projectDir, '/') . '/storage';
|
|
}
|
|
|
|
/**
|
|
* Set the tenant context for scoped operations
|
|
*/
|
|
public function setTenantContext(?string $tenantId): void
|
|
{
|
|
$this->tenantId = $tenantId;
|
|
}
|
|
|
|
/**
|
|
* Set the user context for scoped operations
|
|
*/
|
|
public function setUserContext(?string $userId): void
|
|
{
|
|
$this->userId = $userId;
|
|
}
|
|
|
|
/**
|
|
* @inheritDoc
|
|
*/
|
|
public function get(string $key, CacheScope $scope, string $usage): ?string
|
|
{
|
|
$path = $this->buildPath($key, $scope, $usage);
|
|
|
|
if (!$this->isValid($path)) {
|
|
return null;
|
|
}
|
|
|
|
$content = @file_get_contents($path);
|
|
|
|
return $content !== false ? $content : null;
|
|
}
|
|
|
|
/**
|
|
* @inheritDoc
|
|
*/
|
|
public function getStream(string $key, CacheScope $scope, string $usage)
|
|
{
|
|
$path = $this->buildPath($key, $scope, $usage);
|
|
|
|
if (!$this->isValid($path)) {
|
|
return null;
|
|
}
|
|
|
|
$handle = @fopen($path, 'rb');
|
|
|
|
return $handle !== false ? $handle : null;
|
|
}
|
|
|
|
/**
|
|
* @inheritDoc
|
|
*/
|
|
public function set(string $key, string $data, CacheScope $scope, string $usage, ?string $mimeType = null, ?int $ttl = null): bool
|
|
{
|
|
$path = $this->buildPath($key, $scope, $usage);
|
|
$dir = dirname($path);
|
|
|
|
if (!is_dir($dir)) {
|
|
if (!mkdir($dir, 0755, true) && !is_dir($dir)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Write data file
|
|
$tempPath = $path . '.tmp.' . getmypid();
|
|
|
|
if (file_put_contents($tempPath, $data, LOCK_EX) === false) {
|
|
return false;
|
|
}
|
|
|
|
chmod($tempPath, 0600);
|
|
|
|
if (!rename($tempPath, $path)) {
|
|
@unlink($tempPath);
|
|
return false;
|
|
}
|
|
|
|
// Write metadata
|
|
$this->writeMetadata($path, [
|
|
'mimeType' => $mimeType,
|
|
'size' => strlen($data),
|
|
'createdAt' => time(),
|
|
'expiresAt' => $ttl !== null && $ttl > 0 ? time() + $ttl : null,
|
|
]);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* @inheritDoc
|
|
*/
|
|
public function putStream(string $key, $stream, CacheScope $scope, string $usage, ?string $mimeType = null, ?int $ttl = null): bool
|
|
{
|
|
$path = $this->buildPath($key, $scope, $usage);
|
|
$dir = dirname($path);
|
|
|
|
if (!is_dir($dir)) {
|
|
if (!mkdir($dir, 0755, true) && !is_dir($dir)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Write data file from stream
|
|
$tempPath = $path . '.tmp.' . getmypid();
|
|
$dest = @fopen($tempPath, 'wb');
|
|
|
|
if ($dest === false) {
|
|
return false;
|
|
}
|
|
|
|
$size = 0;
|
|
while (!feof($stream)) {
|
|
$chunk = fread($stream, 8192);
|
|
if ($chunk === false) {
|
|
fclose($dest);
|
|
@unlink($tempPath);
|
|
return false;
|
|
}
|
|
$written = fwrite($dest, $chunk);
|
|
if ($written === false) {
|
|
fclose($dest);
|
|
@unlink($tempPath);
|
|
return false;
|
|
}
|
|
$size += $written;
|
|
}
|
|
|
|
fclose($dest);
|
|
chmod($tempPath, 0600);
|
|
|
|
if (!rename($tempPath, $path)) {
|
|
@unlink($tempPath);
|
|
return false;
|
|
}
|
|
|
|
// Write metadata
|
|
$this->writeMetadata($path, [
|
|
'mimeType' => $mimeType,
|
|
'size' => $size,
|
|
'createdAt' => time(),
|
|
'expiresAt' => $ttl !== null && $ttl > 0 ? time() + $ttl : null,
|
|
]);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* @inheritDoc
|
|
*/
|
|
public function has(string $key, CacheScope $scope, string $usage): bool
|
|
{
|
|
$path = $this->buildPath($key, $scope, $usage);
|
|
|
|
return $this->isValid($path);
|
|
}
|
|
|
|
/**
|
|
* @inheritDoc
|
|
*/
|
|
public function delete(string $key, CacheScope $scope, string $usage): bool
|
|
{
|
|
$path = $this->buildPath($key, $scope, $usage);
|
|
$metaPath = $path . '.meta';
|
|
|
|
$result = true;
|
|
|
|
if (file_exists($path)) {
|
|
$result = @unlink($path);
|
|
}
|
|
|
|
if (file_exists($metaPath)) {
|
|
@unlink($metaPath);
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* @inheritDoc
|
|
*/
|
|
public function getPath(string $key, CacheScope $scope, string $usage): ?string
|
|
{
|
|
$path = $this->buildPath($key, $scope, $usage);
|
|
|
|
if (!$this->isValid($path)) {
|
|
return null;
|
|
}
|
|
|
|
return $path;
|
|
}
|
|
|
|
/**
|
|
* @inheritDoc
|
|
*/
|
|
public function getMetadata(string $key, CacheScope $scope, string $usage): ?array
|
|
{
|
|
$path = $this->buildPath($key, $scope, $usage);
|
|
|
|
if (!file_exists($path)) {
|
|
return null;
|
|
}
|
|
|
|
return $this->readMetadata($path);
|
|
}
|
|
|
|
/**
|
|
* @inheritDoc
|
|
*/
|
|
public function clear(CacheScope $scope, string $usage): int
|
|
{
|
|
$dir = $this->buildDir($scope, $usage);
|
|
|
|
if (!is_dir($dir)) {
|
|
return 0;
|
|
}
|
|
|
|
$count = 0;
|
|
$files = glob($dir . '/*');
|
|
|
|
foreach ($files as $file) {
|
|
if (is_file($file) && !str_ends_with($file, '.meta')) {
|
|
if (@unlink($file)) {
|
|
$count++;
|
|
// Also remove metadata file
|
|
@unlink($file . '.meta');
|
|
}
|
|
}
|
|
}
|
|
|
|
return $count;
|
|
}
|
|
|
|
/**
|
|
* @inheritDoc
|
|
*/
|
|
public function cleanup(): int
|
|
{
|
|
$count = 0;
|
|
$now = time();
|
|
|
|
// Scan all tenant directories
|
|
$tenantDirs = glob($this->basePath . '/*', GLOB_ONLYDIR);
|
|
|
|
foreach ($tenantDirs as $tenantDir) {
|
|
$files = $this->findBlobFiles($tenantDir);
|
|
|
|
foreach ($files as $file) {
|
|
$meta = $this->readMetadata($file);
|
|
|
|
if ($meta !== null && $meta['expiresAt'] !== null && $meta['expiresAt'] < $now) {
|
|
if (@unlink($file)) {
|
|
@unlink($file . '.meta');
|
|
$count++;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return $count;
|
|
}
|
|
|
|
/**
|
|
* Check if a blob is valid (exists and not expired)
|
|
*/
|
|
private function isValid(string $path): bool
|
|
{
|
|
if (!file_exists($path)) {
|
|
return false;
|
|
}
|
|
|
|
$meta = $this->readMetadata($path);
|
|
|
|
if ($meta !== null && $meta['expiresAt'] !== null && $meta['expiresAt'] < time()) {
|
|
@unlink($path);
|
|
@unlink($path . '.meta');
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Build the full path for a blob
|
|
*/
|
|
private function buildPath(string $key, CacheScope $scope, string $usage): string
|
|
{
|
|
$dir = $this->buildDir($scope, $usage);
|
|
$hash = $this->hashKey($key);
|
|
|
|
return $dir . '/' . $hash;
|
|
}
|
|
|
|
/**
|
|
* Build the directory path for a scope/usage combination
|
|
*/
|
|
private function buildDir(CacheScope $scope, string $usage): string
|
|
{
|
|
$usage = preg_replace('/[^a-zA-Z0-9_-]/', '_', $usage);
|
|
|
|
return match ($scope) {
|
|
CacheScope::Global => $this->basePath . '/_global/cache/' . $usage,
|
|
CacheScope::Tenant => $this->basePath . '/' . ($this->tenantId ?? '_unknown') . '/cache/' . $usage,
|
|
CacheScope::User => $this->basePath . '/' . ($this->tenantId ?? '_unknown') . '/' . ($this->userId ?? '_unknown') . '/cache/' . $usage,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Hash a cache key for filesystem safety
|
|
*/
|
|
private function hashKey(string $key): string
|
|
{
|
|
// Extract extension if present in key
|
|
$ext = '';
|
|
if (preg_match('/\.([a-zA-Z0-9]{2,5})$/', $key, $matches)) {
|
|
$ext = '.' . strtolower($matches[1]);
|
|
}
|
|
|
|
$safe = preg_replace('/[^a-zA-Z0-9_-]/', '_', substr($key, 0, 32));
|
|
$hash = substr(hash('sha256', $key), 0, 16);
|
|
|
|
return $safe . '_' . $hash . $ext;
|
|
}
|
|
|
|
/**
|
|
* Read metadata for a blob
|
|
*/
|
|
private function readMetadata(string $path): ?array
|
|
{
|
|
$metaPath = $path . '.meta';
|
|
|
|
if (!file_exists($metaPath)) {
|
|
// Return basic metadata from file stats
|
|
if (file_exists($path)) {
|
|
$stat = stat($path);
|
|
return [
|
|
'mimeType' => null,
|
|
'size' => $stat['size'] ?? 0,
|
|
'createdAt' => $stat['ctime'] ?? time(),
|
|
'expiresAt' => null,
|
|
];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
$content = @file_get_contents($metaPath);
|
|
|
|
if ($content === false) {
|
|
return null;
|
|
}
|
|
|
|
$meta = @unserialize($content);
|
|
|
|
return is_array($meta) ? $meta : null;
|
|
}
|
|
|
|
/**
|
|
* Write metadata for a blob
|
|
*/
|
|
private function writeMetadata(string $path, array $metadata): bool
|
|
{
|
|
$metaPath = $path . '.meta';
|
|
|
|
return file_put_contents($metaPath, serialize($metadata), LOCK_EX) !== false;
|
|
}
|
|
|
|
/**
|
|
* Recursively find all blob files in a directory
|
|
*/
|
|
private function findBlobFiles(string $dir): array
|
|
{
|
|
$files = [];
|
|
|
|
$cacheDirs = glob($dir . '/cache/*', GLOB_ONLYDIR) ?: [];
|
|
$cacheDirs = array_merge($cacheDirs, glob($dir . '/*/cache/*', GLOB_ONLYDIR) ?: []);
|
|
|
|
foreach ($cacheDirs as $cacheDir) {
|
|
$blobFiles = glob($cacheDir . '/*');
|
|
foreach ($blobFiles as $file) {
|
|
if (is_file($file) && !str_ends_with($file, '.meta')) {
|
|
$files[] = $file;
|
|
}
|
|
}
|
|
}
|
|
|
|
return $files;
|
|
}
|
|
}
|