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