* @license GPLv2 * @package * */ class common_persistence_PhpFileDriver implements common_persistence_KvDriver, common_persistence_Purgable { /** * The TTL mode offset in the connection parameters. */ const OPTION_TTL = 'ttlMode'; /** * The value offset in the record. */ const ENTRY_VALUE = 'value'; /** * The expiration timestamp of the record. */ const ENTRY_EXPIRATION = 'expiresAt'; /** * List of characters permited in filename * @var array */ private static $ALLOWED_CHARACTERS = ['A' => '','B' => '','C' => '','D' => '','E' => '','F' => '','G' => '','H' => '','I' => '','J' => '','K' => '','L' => '','M' => '','N' => '','O' => '','P' => '','Q' => '','R' => '','S' => '','T' => '','U' => '','V' => '','W' => '','X' => '','Y' => '','Z' => '','a' => '','b' => '','c' => '','d' => '','e' => '','f' => '','g' => '','h' => '','i' => '','j' => '','k' => '','l' => '','m' => '','n' => '','o' => '','p' => '','q' => '','r' => '','s' => '','t' => '','u' => '','v' => '','w' => '','x' => '','y' => '','z' => '',0 => '',1 => '',2 => '',3 => '',4 => '',5 => '',6 => '',7 => '',8 => '',9 => '','_' => '','-' => '']; /** * absolute path of the directory to use * ending on a directory seperator * * @var string */ private $directory; /** * Nr of subfolder levels in order to prevent filesystem bottlenecks * Only used in non human readable mode * * @var int */ private $levels; /** * Whenever or not the filenames should be human readable * FALSE by default for performance issues with many keys * * @var boolean */ private $humanReadable; /** * @var bool */ private $ttlMode; /** * Using 3 default levels, so the files get split up into * 16^3 = 4096 induvidual directories * * @var int */ const DEFAULT_LEVELS = 3; const DEFAULT_MASK = 0700; /** * (non-PHPdoc) * @see common_persistence_Driver::connect() */ public function connect($id, array $params) { $this->directory = isset($params['dir']) ? $params['dir'] . ($params['dir'][strlen($params['dir']) - 1] === DIRECTORY_SEPARATOR ? '' : DIRECTORY_SEPARATOR) : FILES_PATH . 'generis' . DIRECTORY_SEPARATOR . $id . DIRECTORY_SEPARATOR; $this->levels = isset($params['levels']) ? $params['levels'] : self::DEFAULT_LEVELS; $this->humanReadable = isset($params['humanReadable']) ? $params['humanReadable'] : false; // Sets ttl mode TRUE when the passed ttl mode is true. $this->setTtlMode( (isset($params[static::OPTION_TTL]) && $params[static::OPTION_TTL] == true) ); return new common_persistence_KeyValuePersistence($params, $this); } /** * (non-PHPdoc) * @see common_persistence_KvDriver::set() * * @throws common_exception_NotImplemented * @throws \common_exception_Error */ public function set($id, $value, $ttl = null, $nx = false) { if ($this->isTtlMode()) { $value = [ static::ENTRY_VALUE => $value, static::ENTRY_EXPIRATION => $this->calculateExpiresAt($ttl), ]; } elseif (null !== $ttl) { throw new common_exception_NotImplemented('TTL not implemented in ' . __CLASS__); } if ($nx) { throw new common_exception_NotImplemented('NX not implemented in ' . __CLASS__); } return $this->writeFile($id, $value); } /** * Calculates and returns the expires at timestamp or null on empty ttl. * * @param $ttl * * @return int|null */ protected function calculateExpiresAt($ttl) { return $ttl === null ? null : $this->getTime() + $ttl ; } /** * Writes the file. * * @param $id * @param $value * @param callable $preWriteValueProcessor The value preprocessor method. * * @return bool * * @throws \common_exception_Error */ private function writeFile($id, $value, $preWriteValueProcessor = null) { $filePath = $this->getPath($id); $this->makeDirectory(dirname($filePath), self::DEFAULT_MASK); // we first open with 'c' in case the flock fails // 'w' would empty the file that someone else might be working on if (false !== ($fp = @fopen($filePath, 'c')) && true === flock($fp, LOCK_EX)) { // Runs the pre write callable. if (is_callable($preWriteValueProcessor)) { $value = call_user_func($preWriteValueProcessor, $id); } // We first need to truncate. ftruncate($fp, 0); $string = $this->getContent($id, $value); $success = fwrite($fp, $string); @flock($fp, LOCK_UN); @fclose($fp); if ($success) { // OPcache workaround if (function_exists('opcache_invalidate')) { opcache_invalidate($filePath, true); } } else { common_Logger::w('Could not write ' . $filePath); } return $success !== false; } else { common_Logger::w('Could not obtain lock on ' . $filePath); return false; } } /** * Create directory and suppress warning message * @param $path * @param int $mode * @return bool */ private function makeDirectory(string $path, int $mode) { if (is_dir($path) || @mkdir($path, $mode, true)) { return true; } if (is_dir($path)) { \common_Logger::w(sprintf('Directory already exists. Path: \'%s\'', $path)); } elseif (is_file($path)) { \common_Logger::w(sprintf('Directory was not created. File with the same name already exists. Path: \'%s\'', $path)); } else { \common_Logger::w(sprintf('Directory was not created. Path: \'%s\'', $path)); } return false; } /** * (non-PHPdoc) * @see common_persistence_KvDriver::get() */ public function get($id) { $entry = $this->readFile($id); if ($entry != false && $this->isTtlMode()) { $entry = (is_null($entry[static::ENTRY_EXPIRATION]) || $entry[static::ENTRY_EXPIRATION] > $this->getTime()) ? $entry[static::ENTRY_VALUE] : false ; } return $entry; } /** * Returns the processed entry. * * @param $id * * @return mixed */ private function readFile($id) { return @include $this->getPath($id); } /** * Returns the current timestamp. * * @return int */ public function getTime() { return time(); } /** * (non-PHPdoc) * @see common_persistence_KvDriver::exists() */ public function exists($id) { if (!$this->isTtlMode()) { return file_exists($this->getPath($id)); } else { return $this->get($id) !== false; } } /** * (non-PHPdoc) * @see common_persistence_KvDriver::del() */ public function del($id) { $filePath = $this->getPath($id); // invalidate opcache first, fails on already deleted file if (function_exists('opcache_invalidate')) { opcache_invalidate($filePath, true); } $success = @unlink($filePath); return $success; } /** * Increment existing value * * @param string $id * * @return mixed * * @throws \common_exception_Error */ public function incr($id) { return $this->writeFile($id, '', [$this, 'getIncreasedValueEntry']); } /** * Returns the increased value entry. * * @param $id * * @return mixed */ private function getIncreasedValueEntry($id) { $value = intval($this->get($id)); $value++; if ($this->isTtlMode()) { $value = [ static::ENTRY_VALUE => $value, static::ENTRY_EXPIRATION => null, ]; } return $value; } /** * Decrement existing value * * @param $id * * @return mixed * * @throws \common_exception_Error */ public function decr($id) { return $this->writeFile($id, '', [$this, 'getDecreasedValueEntry']); } /** * Returns the decreased value entry. * * @param $id * * @return mixed */ private function getDecreasedValueEntry($id) { $value = intval($this->get($id)); $value--; if ($this->isTtlMode()) { $value = [ static::ENTRY_VALUE => $value, static::ENTRY_EXPIRATION => null, ]; } return $value; } /** * purge the persistence directory * * @return boolean */ public function purge() { if (file_exists($this->directory)) { $files = $this->getCachedFiles(); $successDeleted = true; foreach ($files as $file) { $successDeleted &= $this->removeCacheFile($file); } return (bool)$successDeleted; } return false; } /** * Map the provided key to a relativ path * * @param string $key * @return string */ protected function getPath($key) { if ($this->humanReadable) { $path = $this->sanitizeReadableFileName($key); } else { $encoded = hash('md5', $key); $path = implode(DIRECTORY_SEPARATOR, str_split(substr($encoded, 0, $this->levels))) . DIRECTORY_SEPARATOR . $encoded; } return $this->directory . $path . '.php'; } /** * Cannot use helpers_File::sanitizeInjectively() because * of backwards compatibility * * @param string $key * @return string */ protected function sanitizeReadableFileName($key) { $path = ''; foreach (str_split($key) as $char) { $path .= isset(self::$ALLOWED_CHARACTERS[$char]) ? $char : base64_encode($char); } return $path; } /** * Generate the php code that returns the provided value * * @param string $key * @param mixed $value * * @return string * * @throws \common_exception_Error */ protected function getContent($key, $value) { return $this->humanReadable ? "ttlMode; } /** * Sets the TTL mode. * * @param bool $ttlMode */ public function setTtlMode($ttlMode) { $this->ttlMode = $ttlMode; } /** * @return array */ private function getCachedFiles() { try { $files = helpers_File::scandir($this->directory, [ 'recursive' => true, 'only' => helpers_File::SCAN_FILE, 'absolute' => true, ]); } catch (common_Exception $exception) { \common_Logger::e($exception->getMessage()); return []; } return $files; } /** * @param string $filePath * @return bool */ private function removeCacheFile($filePath) { try { if (function_exists('opcache_invalidate')) { opcache_invalidate($filePath, true); } return helpers_File::remove($filePath); } catch (common_exception_Error $exception) { \common_Logger::e($exception->getMessage()); return false; } } }