* @copyright 2011-2026 Nicola Asuni - Tecnick.com LTD * @license https://www.gnu.org/copyleft/lesser.html GNU-LGPL v3 (see LICENSE) * @link https://github.com/tecnickcom/tc-lib-file * * This file is part of tc-lib-file software library. */ namespace Com\Tecnick\File; /** * Com\Tecnick\File\Cache * * File caching system with per-instance path and prefix. * Each Cache instance maintains its own cache directory path and file prefix. * * @since 2011-05-23 * @category Library * @package File * @author Nicola Asuni * @copyright 2011-2026 Nicola Asuni - Tecnick.com LTD * @license https://www.gnu.org/copyleft/lesser.html GNU-LGPL v3 (see LICENSE) * @link https://github.com/tecnickcom/tc-lib-file */ class Cache { /** * Pattern matching characters that are not allowed in cache filename tokens * (prefix, type, key). Stripping them keeps generated names valid on every * platform (notably Windows) and prevents glob metacharacters from reaching * delete()/deleteOlderThan(). getNewFileName() and delete() must use the * same pattern so the prefix-based glob matches the files actually created. */ private const SAFE_NAME_PATTERN = '/[^a-zA-Z0-9_\-]/'; /** * Cache path (per-instance) * * @var string */ protected string $path = ''; /** * File prefix (per-instance) */ protected string $prefix; /** * Set the file prefix (common name) * * @param ?string $prefix Common prefix to be used for all cache files */ public function __construct(?string $prefix = null) { $this->defineSystemCachePath(); $this->setCachePath(); $prefix ??= \rtrim( \base64_encode(\pack('H*', \md5(\uniqid((string) \mt_rand(0, \mt_getrandmax()), true)))), '=', ); $safePrefix = \preg_replace(self::SAFE_NAME_PATTERN, '', \strtr($prefix, '+/', '-_')) ?? ''; $this->prefix = '_' . $safePrefix . '_'; } /** * Get the cache directory path */ public function getCachePath(): string { return $this->path; } /** * Set the default cache directory path * * @param ?string $path Cache directory path; if null use the K_PATH_CACHE value */ public function setCachePath(?string $path = null): void { if ($path === null || \str_contains($path, '://') || !\is_writable($path)) { // Normalize the fallback too: K_PATH_CACHE may be supplied by the host // application without a trailing separator, and getNewFileName()/delete() // concatenate $this->path directly. Without normalization the generated // file would escape the cache directory (e.g. ".../cachedir" + "_pfx_..." // lands next to the directory instead of inside it). $this->path = $this->normalizePath((string) \constant('K_PATH_CACHE')); return; } $this->path = $this->normalizePath($path); } /** * Get the file prefix */ public function getFilePrefix(): string { return $this->prefix; } /** * Returns a temporary filename for caching files. * Throws an exception when tempnam() fails, consistent with the rest of the library. * * @param string $type Type of file * @param string $key File key (used to retrieve file from cache) * * @return string Temporary filename * * @throws \Com\Tecnick\File\Exception when a temporary file cannot be created. */ public function getNewFileName(string $type = 'tmp', string $key = '0'): string { // Sanitize so the generated name matches the patterns used by delete() // and contains no characters that are invalid in Windows filenames. $safeType = \preg_replace(self::SAFE_NAME_PATTERN, '', $type) ?? ''; $safeKey = \preg_replace(self::SAFE_NAME_PATTERN, '', $key) ?? ''; // tempnam() atomically creates a unique, private file, but on Windows it // keeps only the first three characters of the prefix. Create the file // with tempnam(), then rename it to a name carrying the full prefix so // the prefix-based glob in delete()/deleteOlderThan() works on every // platform. The rename preserves tempnam()'s restrictive permissions. $file = \tempnam($this->path, $this->prefix); if ($file === false) { throw new Exception('unable to create a temporary file in: ' . $this->path); } $unique = \str_replace('.', '', \uniqid('', true)); $target = $this->path . $this->prefix . $safeType . '_' . $safeKey . '_' . $unique; $renamed = $this->withoutFsWarnings(static fn(): bool => \rename($file, $target)); return $renamed ? $target : $file; } /** * Delete cached files * * @param ?string $type Type of files to delete * @param ?string $key Specific file key to delete */ public function delete(?string $type = null, ?string $key = null): void { $safeType = $type !== null ? \preg_replace(self::SAFE_NAME_PATTERN, '', $type) : null; $safeKey = $key !== null ? \preg_replace(self::SAFE_NAME_PATTERN, '', $key) : null; $path = $this->path . $this->prefix; if ($safeType !== null) { $path .= $safeType . '_'; if ($safeKey !== null) { $path .= $safeKey . '_'; } } $path .= '*'; $files = \glob($path); if ($files === [] || $files === false) { return; } // Suppress warnings: a file may vanish (concurrent cleanup, external // process) between glob() and unlink(); such races are non-fatal. $this->withoutFsWarnings(static function () use ($files): void { foreach ($files as $file) { \unlink($file); } }); } /** * Delete cache files older than the given number of seconds. * * @param int $seconds Maximum age in seconds; files whose mtime is older are removed. */ public function deleteOlderThan(int $seconds): void { $pattern = $this->path . $this->prefix . '*'; $files = \glob($pattern); if ($files === [] || $files === false) { return; } $cutoff = \time() - $seconds; // Suppress warnings: filemtime()/unlink() may fail if a file disappears // between glob() and the call (concurrent cleanup); such races are non-fatal. $this->withoutFsWarnings(static function () use ($files, $cutoff): void { foreach ($files as $file) { $mtime = \filemtime($file); if ($mtime !== false && $mtime < $cutoff) { \unlink($file); } } }); } /** * Execute a callable while suppressing expected filesystem warnings. * * The cache helpers race against other processes and against files that may * vanish between glob() and the operation; those calls already signal failure * via their return values, so the raw PHP warnings only add noise. * * @template T * * @param callable():T $callback * * @return T */ private function withoutFsWarnings(callable $callback): mixed { \set_error_handler(static fn(): bool => true, E_WARNING); try { return $callback(); } finally { \restore_error_handler(); } } /** * Set the K_PATH_CACHE constant (if not set) to the default system directory for temporary files */ protected function defineSystemCachePath(): void { if (\defined('K_PATH_CACHE')) { return; } $kPathCache = \ini_get('upload_tmp_dir'); if ($kPathCache === false || $kPathCache === '') { $kPathCache = \sys_get_temp_dir(); } \define('K_PATH_CACHE', $this->normalizePath($kPathCache)); } /** * Normalize cache path * * @param string $path Path to normalize */ protected function normalizePath(string $path): string { $rpath = \realpath($path); if ($rpath === false) { return ''; } if (!\str_ends_with($rpath, \DIRECTORY_SEPARATOR)) { $rpath .= \DIRECTORY_SEPARATOR; } return $rpath; } }