Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.38% covered (warning)
84.38%
108 / 128
63.64% covered (warning)
63.64%
14 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
common_persistence_PhpFileDriver
84.38% covered (warning)
84.38%
108 / 128
63.64% covered (warning)
63.64%
14 / 22
69.39
0.00% covered (danger)
0.00%
0 / 1
 connect
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
6.04
 set
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
4.13
 calculateExpiresAt
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 writeFile
82.35% covered (warning)
82.35%
14 / 17
0.00% covered (danger)
0.00%
0 / 1
6.20
 makeDirectory
50.00% covered (danger)
50.00%
6 / 12
0.00% covered (danger)
0.00%
0 / 1
6.00
 get
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 readFile
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getTime
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 exists
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 del
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 incr
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getIncreasedValueEntry
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 decr
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDecreasedValueEntry
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 purge
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 getPath
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 sanitizeReadableFileName
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getContent
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 isTtlMode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setTtlMode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCachedFiles
66.67% covered (warning)
66.67%
6 / 9
0.00% covered (danger)
0.00%
0 / 1
2.15
 removeCacheFile
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
4.12
1<?php
2
3/**
4 * This program is free software; you can redistribute it and/or
5 * modify it under the terms of the GNU General Public License
6 * as published by the Free Software Foundation; under version 2
7 * of the License (non-upgradable).
8 *
9 * This program is distributed in the hope that it will be useful,
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 * GNU General Public License for more details.
13 *
14 * You should have received a copy of the GNU General Public License
15 * along with this program; if not, write to the Free Software
16 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
17 *
18 * Copyright (c) 2013 (original work) Open Assessment Technologies SA (under the project TAO-PRODUCT);
19 *
20 * @author Lionel Lecaque  <lionel@taotesting.com>
21 * @license GPLv2
22 * @package
23
24 *
25 */
26class common_persistence_PhpFileDriver implements common_persistence_KvDriver, common_persistence_Purgable
27{
28    /**
29     * The TTL mode offset in the connection parameters.
30     */
31    public const OPTION_TTL = 'ttlMode';
32
33    /**
34     * The value offset in the record.
35     */
36    public const ENTRY_VALUE = 'value';
37
38    /**
39     * The expiration timestamp of the record.
40     */
41    public const ENTRY_EXPIRATION = 'expiresAt';
42
43    /**
44     * List of characters permited in filename
45     * @var array
46     */
47    private static $ALLOWED_CHARACTERS = [
48        'A' => '',
49        'B' => '',
50        'C' => '',
51        'D' => '',
52        'E' => '',
53        'F' => '',
54        'G' => '',
55        'H' => '',
56        'I' => '',
57        'J' => '',
58        'K' => '',
59        'L' => '',
60        'M' => '',
61        'N' => '',
62        'O' => '',
63        'P' => '',
64        'Q' => '',
65        'R' => '',
66        'S' => '',
67        'T' => '',
68        'U' => '',
69        'V' => '',
70        'W' => '',
71        'X' => '',
72        'Y' => '',
73        'Z' => '',
74        'a' => '',
75        'b' => '',
76        'c' => '',
77        'd' => '',
78        'e' => '',
79        'f' => '',
80        'g' => '',
81        'h' => '',
82        'i' => '',
83        'j' => '',
84        'k' => '',
85        'l' => '',
86        'm' => '',
87        'n' => '',
88        'o' => '',
89        'p' => '',
90        'q' => '',
91        'r' => '',
92        's' => '',
93        't' => '',
94        'u' => '',
95        'v' => '',
96        'w' => '',
97        'x' => '',
98        'y' => '',
99        'z' => '',
100        0 => '',
101        1 => '',
102        2 => '',
103        3 => '',
104        4 => '',
105        5 => '',
106        6 => '',
107        7 => '',
108        8 => '',
109        9 => '',
110        '_' => '',
111        '-' => '',
112    ];
113
114    /**
115     * absolute path of the directory to use
116     * ending on a directory seperator
117     *
118     * @var string
119     */
120    private $directory;
121
122    /**
123     * Nr of subfolder levels in order to prevent filesystem bottlenecks
124     * Only used in non human readable mode
125     *
126     * @var int
127     */
128    private $levels;
129
130    /**
131     * Whenever or not the filenames should be human readable
132     * FALSE by default for performance issues with many keys
133     *
134     * @var boolean
135     */
136    private $humanReadable;
137
138    /**
139     * @var bool
140     */
141    private $ttlMode;
142
143    /**
144     * Using 3 default levels, so the files get split up into
145     * 16^3 = 4096 induvidual directories
146     *
147     * @var int
148     */
149    public const DEFAULT_LEVELS = 3;
150
151    public const DEFAULT_MASK = 0700;
152
153    /**
154     * (non-PHPdoc)
155     * @see common_persistence_Driver::connect()
156     */
157    public function connect($id, array $params)
158    {
159        $this->directory = isset($params['dir'])
160            ? $params['dir']
161                . ($params['dir'][strlen($params['dir']) - 1] === DIRECTORY_SEPARATOR ? '' : DIRECTORY_SEPARATOR)
162            : FILES_PATH . 'generis' . DIRECTORY_SEPARATOR . $id . DIRECTORY_SEPARATOR;
163        $this->levels = isset($params['levels']) ? $params['levels'] : self::DEFAULT_LEVELS;
164        $this->humanReadable = isset($params['humanReadable']) ? $params['humanReadable'] : false;
165
166        // Sets ttl mode TRUE when the passed ttl mode is true.
167        $this->setTtlMode(
168            (isset($params[static::OPTION_TTL]) && $params[static::OPTION_TTL] == true)
169        );
170
171        return new common_persistence_KeyValuePersistence($params, $this);
172    }
173
174    /**
175     * (non-PHPdoc)
176     * @see common_persistence_KvDriver::set()
177     *
178     * @throws common_exception_NotImplemented
179     * @throws \common_exception_Error
180     */
181    public function set($id, $value, $ttl = null, $nx = false)
182    {
183        if ($this->isTtlMode()) {
184            $value = [
185                static::ENTRY_VALUE      => $value,
186                static::ENTRY_EXPIRATION => $this->calculateExpiresAt($ttl),
187            ];
188        } elseif (null !== $ttl) {
189            throw new common_exception_NotImplemented('TTL not implemented in ' . __CLASS__);
190        }
191
192        if ($nx) {
193            throw new common_exception_NotImplemented('NX not implemented in ' . __CLASS__);
194        }
195
196        return $this->writeFile($id, $value);
197    }
198
199    /**
200     * Calculates and returns the expires at timestamp or null on empty ttl.
201     *
202     * @param $ttl
203     *
204     * @return int|null
205     */
206    protected function calculateExpiresAt($ttl)
207    {
208        return $ttl === null
209            ? null
210            : $this->getTime() + $ttl
211        ;
212    }
213
214    /**
215     * Writes the file.
216     *
217     * @param $id
218     * @param $value
219     * @param callable $preWriteValueProcessor   The value preprocessor method.
220     *
221     * @return bool
222     *
223     * @throws \common_exception_Error
224     */
225    private function writeFile($id, $value, $preWriteValueProcessor = null)
226    {
227        $filePath = $this->getPath($id);
228        $this->makeDirectory(dirname($filePath), self::DEFAULT_MASK);
229
230        // we first open with 'c' in case the flock fails
231        // 'w' would empty the file that someone else might be working on
232        if (false !== ($fp = @fopen($filePath, 'c')) && true === flock($fp, LOCK_EX)) {
233            // Runs the pre write callable.
234            if (is_callable($preWriteValueProcessor)) {
235                $value = call_user_func($preWriteValueProcessor, $id);
236            }
237
238            // We first need to truncate.
239            ftruncate($fp, 0);
240            $string = $this->getContent($id, $value);
241            $success = fwrite($fp, $string);
242            @flock($fp, LOCK_UN);
243            @fclose($fp);
244            if ($success) {
245                // OPcache workaround
246                if (function_exists('opcache_invalidate')) {
247                    opcache_invalidate($filePath, true);
248                }
249            } else {
250                common_Logger::w('Could not write ' . $filePath);
251            }
252
253            return $success !== false;
254        } else {
255            common_Logger::w('Could not obtain lock on ' . $filePath);
256
257            return false;
258        }
259    }
260
261    /**
262     * Create directory and suppress warning message
263     * @param string $path
264     * @param int $mode
265     */
266    private function makeDirectory(string $path, int $mode): void
267    {
268        if (file_exists($path)) {
269            if (is_dir($path)) {
270                $message = sprintf('Directory already exists. Path: "%s"', $path);
271            } elseif (is_file($path)) {
272                $message = sprintf(
273                    'Directory was not created. File with the same name already exists. Path: "%s"',
274                    $path
275                );
276            } else {
277                $message = sprintf('Directory was not created. Path: "%s"', $path);
278            }
279            \common_Logger::i($message);
280
281            return;
282        }
283
284        @mkdir($path, $mode, true);
285    }
286
287    /**
288     * (non-PHPdoc)
289     * @see common_persistence_KvDriver::get()
290     */
291    public function get($id)
292    {
293        $entry = $this->readFile($id);
294        if ($entry != false && $this->isTtlMode()) {
295            $entry = (is_null($entry[static::ENTRY_EXPIRATION]) || $entry[static::ENTRY_EXPIRATION] > $this->getTime())
296                ? $entry[static::ENTRY_VALUE]
297                : false
298            ;
299        }
300        return $entry;
301    }
302
303    /**
304     * Returns the processed entry.
305     *
306     * @param $id
307     *
308     * @return mixed
309     */
310    private function readFile($id)
311    {
312        $path = $this->getPath($id);
313
314        if (is_readable($path)) {
315            return @include $path;
316        }
317
318        return false;
319    }
320
321    /**
322     * Returns the current timestamp.
323     *
324     * @return int
325     */
326    public function getTime()
327    {
328        return time();
329    }
330
331    /**
332     * (non-PHPdoc)
333     * @see common_persistence_KvDriver::exists()
334     */
335    public function exists($id)
336    {
337        if (!$this->isTtlMode()) {
338            return file_exists($this->getPath($id));
339        } else {
340            return $this->get($id) !== false;
341        }
342    }
343
344    /**
345     * (non-PHPdoc)
346     * @see common_persistence_KvDriver::del()
347     */
348    public function del($id)
349    {
350        $filePath = $this->getPath($id);
351
352        // invalidate opcache first, fails on already deleted file
353        if (function_exists('opcache_invalidate')) {
354            opcache_invalidate($filePath, true);
355        }
356
357        $success = @unlink($filePath);
358        return $success;
359    }
360
361    /**
362     * Increment existing value
363     *
364     * @param string $id
365     *
366     * @return mixed
367     *
368     * @throws \common_exception_Error
369     */
370    public function incr($id)
371    {
372        return $this->writeFile($id, '', [$this, 'getIncreasedValueEntry']);
373    }
374
375    /**
376     * Returns the increased value entry.
377     *
378     * @param $id
379     *
380     * @return mixed
381     */
382    private function getIncreasedValueEntry($id)
383    {
384        $value = intval($this->get($id));
385        $value++;
386        if ($this->isTtlMode()) {
387            $value = [
388                static::ENTRY_VALUE      => $value,
389                static::ENTRY_EXPIRATION => null,
390            ];
391        }
392        return $value;
393    }
394
395    /**
396     * Decrement existing value
397     *
398     * @param $id
399     *
400     * @return mixed
401     *
402     * @throws \common_exception_Error
403     */
404    public function decr($id)
405    {
406        return $this->writeFile($id, '', [$this, 'getDecreasedValueEntry']);
407    }
408
409    /**
410     * Returns the decreased value entry.
411     *
412     * @param $id
413     *
414     * @return mixed
415     */
416    private function getDecreasedValueEntry($id)
417    {
418        $value = intval($this->get($id));
419        $value--;
420        if ($this->isTtlMode()) {
421            $value = [
422                static::ENTRY_VALUE      => $value,
423                static::ENTRY_EXPIRATION => null,
424            ];
425        }
426        return $value;
427    }
428
429    /**
430     * purge the persistence directory
431     *
432     * @return boolean
433     */
434    public function purge()
435    {
436        if (file_exists($this->directory)) {
437            $files          = $this->getCachedFiles();
438            $successDeleted = true;
439            foreach ($files as $file) {
440                $successDeleted &= $this->removeCacheFile($file);
441            }
442
443            return (bool)$successDeleted;
444        }
445
446        return false;
447    }
448
449    /**
450     * Map the provided key to a relativ path
451     *
452     * @param string $key
453     * @return string
454     */
455    protected function getPath($key)
456    {
457        if ($this->humanReadable) {
458            $path = $this->sanitizeReadableFileName($key);
459        } else {
460            $encoded = hash('md5', $key);
461            $path = implode(DIRECTORY_SEPARATOR, str_split(substr($encoded, 0, $this->levels)))
462                . DIRECTORY_SEPARATOR . $encoded;
463        }
464        return  $this->directory . $path . '.php';
465    }
466
467    /**
468     * Cannot use helpers_File::sanitizeInjectively() because
469     * of backwards compatibility
470     *
471     * @param string $key
472     * @return string
473     */
474    protected function sanitizeReadableFileName($key)
475    {
476        $path = '';
477        foreach (str_split($key) as $char) {
478            $path .= isset(self::$ALLOWED_CHARACTERS[$char]) ? $char : base64_encode($char);
479        }
480        return $path;
481    }
482
483    /**
484     * Generate the php code that returns the provided value
485     *
486     * @param string $key
487     * @param mixed $value
488     *
489     * @return string
490     *
491     * @throws \common_exception_Error
492     */
493    protected function getContent($key, $value)
494    {
495        return $this->humanReadable
496            ? "<?php return " . common_Utils::toHumanReadablePhpString($value) . ";" . PHP_EOL
497            : "<?php return " . common_Utils::toPHPVariableString($value) . ";";
498    }
499
500    /**
501     * Returns TRUE when the connection is in TTL mode.
502     *
503     * @return bool
504     */
505    public function isTtlMode()
506    {
507        return $this->ttlMode;
508    }
509
510    /**
511     * Sets the TTL mode.
512     *
513     * @param bool $ttlMode
514     */
515    public function setTtlMode($ttlMode)
516    {
517        $this->ttlMode = $ttlMode;
518    }
519
520    /**
521     * @return array
522     */
523    private function getCachedFiles()
524    {
525        try {
526            $files = helpers_File::scandir($this->directory, [
527                'recursive' => true,
528                'only'      => helpers_File::SCAN_FILE,
529                'absolute'  => true,
530            ]);
531        } catch (common_Exception $exception) {
532            \common_Logger::e($exception->getMessage());
533            return [];
534        }
535
536        return $files;
537    }
538
539    /**
540     * @param string $filePath
541     * @return bool
542     */
543    private function removeCacheFile($filePath)
544    {
545        try {
546            if (function_exists('opcache_invalidate')) {
547                opcache_invalidate($filePath, true);
548            }
549
550            return helpers_File::remove($filePath);
551        } catch (common_exception_Error $exception) {
552            \common_Logger::e($exception->getMessage());
553            return false;
554        }
555    }
556}