480 lines
13 KiB
PHP
480 lines
13 KiB
PHP
<?php
|
|
/**
|
|
* This program is free software; you can redistribute it and/or
|
|
* modify it under the terms of the GNU General Public License
|
|
* as published by the Free Software Foundation; under version 2
|
|
* of the License (non-upgradable).
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License
|
|
* along with this program; if not, write to the Free Software
|
|
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
|
*
|
|
* Copyright (c) 2013 (original work) Open Assessment Technologies SA (under the project TAO-PRODUCT);
|
|
*
|
|
* @author Lionel Lecaque <lionel@taotesting.com>
|
|
* @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
|
|
? "<?php return " . common_Utils::toHumanReadablePhpString($value) . ";" . PHP_EOL
|
|
: "<?php return " . common_Utils::toPHPVariableString($value) . ";";
|
|
}
|
|
|
|
/**
|
|
* Returns TRUE when the connection is in TTL mode.
|
|
*
|
|
* @return bool
|
|
*/
|
|
public function isTtlMode()
|
|
{
|
|
return $this->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;
|
|
}
|
|
}
|
|
}
|