Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
82.17% covered (warning)
82.17%
129 / 157
55.56% covered (warning)
55.56%
10 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
FilesCache
82.17% covered (warning)
82.17%
129 / 157
55.56% covered (warning)
55.56%
10 / 18
82.11
0.00% covered (danger)
0.00%
0 / 1
 __destruct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 initialize
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setGC
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 setBaseDirectory
83.33% covered (warning)
83.33%
15 / 18
0.00% covered (danger)
0.00%
0 / 1
6.17
 get
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 getContents
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
5.15
 createSubDirectory
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
5.26
 set
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 setValue
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
4.04
 delete
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 flush
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 gc
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 deleteExpired
75.00% covered (warning)
75.00%
15 / 20
0.00% covered (danger)
0.00%
0 / 1
9.00
 deleteAll
65.22% covered (warning)
65.22%
15 / 23
0.00% covered (danger)
0.00%
0 / 1
12.41
 deleteFile
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
 openDir
66.67% covered (warning)
66.67%
6 / 9
0.00% covered (danger)
0.00%
0 / 1
4.59
 closeDir
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 renderFilepath
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
1<?php declare(strict_types=1);
2/*
3 * This file is part of Aplus Framework Cache Library.
4 *
5 * (c) Natan Felles <natanfelles@gmail.com>
6 *
7 * For the full copyright and license information, please view the LICENSE
8 * file that was distributed with this source code.
9 */
10namespace Framework\Cache;
11
12use InvalidArgumentException;
13use JetBrains\PhpStorm\Pure;
14use RuntimeException;
15
16/**
17 * Class FilesCache.
18 *
19 * @package cache
20 */
21class FilesCache extends Cache
22{
23    /**
24     * Files Cache handler configurations.
25     *
26     * @var array<string,mixed>
27     */
28    protected array $configs = [
29        'directory' => null,
30        'files_permission' => 0644,
31        'gc' => 1,
32    ];
33    /**
34     * @var string|null
35     */
36    protected ?string $baseDirectory;
37
38    public function __destruct()
39    {
40        if (\rand(1, 100) <= $this->configs['gc']) {
41            $this->gc();
42        }
43    }
44
45    protected function initialize() : void
46    {
47        $this->setBaseDirectory();
48        $this->setGC($this->configs['gc']);
49    }
50
51    protected function setGC(int $gc) : void
52    {
53        if ($gc < 1 || $gc > 100) {
54            throw new InvalidArgumentException(
55                "Invalid cache GC: {$gc}"
56            );
57        }
58    }
59
60    protected function setBaseDirectory() : void
61    {
62        $path = $this->configs['directory'];
63        if ($path === null) {
64            $path = \sys_get_temp_dir();
65        }
66        $real = \realpath($path);
67        if ($real === false) {
68            throw new RuntimeException("Invalid cache directory: {$path}");
69        }
70        $real = \rtrim($path, \DIRECTORY_SEPARATOR) . \DIRECTORY_SEPARATOR;
71        if (isset($this->prefix[0])) {
72            $real .= $this->prefix;
73        }
74        if ( ! \is_dir($real)) {
75            throw new RuntimeException(
76                "Invalid cache directory path: {$real}"
77            );
78        }
79        if ( ! \is_writable($real)) {
80            throw new RuntimeException(
81                "Cache directory is not writable: {$real}"
82            );
83        }
84        $this->baseDirectory = $real . \DIRECTORY_SEPARATOR;
85    }
86
87    public function get(string $key) : mixed
88    {
89        if (isset($this->debugCollector)) {
90            $start = \microtime(true);
91            return $this->addDebugGet(
92                $key,
93                $start,
94                $this->getContents($this->renderFilepath($key))
95            );
96        }
97        return $this->getContents($this->renderFilepath($key));
98    }
99
100    /**
101     * @param string $filepath
102     *
103     * @return mixed
104     */
105    protected function getContents(string $filepath) : mixed
106    {
107        if ( ! \is_file($filepath)) {
108            return null;
109        }
110        $value = @\file_get_contents($filepath);
111        if ($value === false) {
112            $this->log("Cache (files): File '{$filepath}' could not be read");
113            return null;
114        }
115        $value = (array) $this->unserialize($value);
116        if ( ! isset($value['ttl'], $value['data']) || $value['ttl'] <= \time()) {
117            $this->deleteFile($filepath);
118            return null;
119        }
120        return $value['data'];
121    }
122
123    protected function createSubDirectory(string $filepath) : void
124    {
125        $dirname = \dirname($filepath);
126        if (\is_dir($dirname)) {
127            return;
128        }
129        if ( ! \mkdir($dirname, 0777, true) || ! \is_dir($dirname)) {
130            throw new RuntimeException(
131                "Directory key was not created: {$filepath}"
132            );
133        }
134    }
135
136    public function set(string $key, mixed $value, int $ttl = null) : bool
137    {
138        if (isset($this->debugCollector)) {
139            $start = \microtime(true);
140            return $this->addDebugSet(
141                $key,
142                $ttl,
143                $start,
144                $value,
145                $this->setValue($key, $value, $ttl)
146            );
147        }
148        return $this->setValue($key, $value, $ttl);
149    }
150
151    public function setValue(string $key, mixed $value, int $ttl = null) : bool
152    {
153        $filepath = $this->renderFilepath($key);
154        $this->createSubDirectory($filepath);
155        $value = [
156            'ttl' => \time() + $this->makeTtl($ttl),
157            'data' => $value,
158        ];
159        $value = $this->serialize($value);
160        $isFile = \is_file($filepath);
161        $written = @\file_put_contents($filepath, $value, \LOCK_EX);
162        if ($written !== false && $isFile === false) {
163            \chmod($filepath, $this->configs['files_permission']);
164        }
165        if ($written === false) {
166            $this->log("Cache (files): File '{$filepath}' could not be written");
167            return false;
168        }
169        return true;
170    }
171
172    public function delete(string $key) : bool
173    {
174        if (isset($this->debugCollector)) {
175            $start = \microtime(true);
176            return $this->addDebugDelete(
177                $key,
178                $start,
179                $this->deleteFile($this->renderFilepath($key))
180            );
181        }
182        return $this->deleteFile($this->renderFilepath($key));
183    }
184
185    public function flush() : bool
186    {
187        if (isset($this->debugCollector)) {
188            $start = \microtime(true);
189            return $this->addDebugFlush(
190                $start,
191                $this->deleteAll($this->baseDirectory)
192            );
193        }
194        return $this->deleteAll($this->baseDirectory);
195    }
196
197    /**
198     * Garbage collector.
199     *
200     * Deletes all expired items.
201     *
202     * @return bool TRUE if all expired items was deleted, FALSE if a fail occurs
203     */
204    public function gc() : bool
205    {
206        return $this->deleteExpired($this->baseDirectory);
207    }
208
209    protected function deleteExpired(string $baseDirectory) : bool
210    {
211        $handle = $this->openDir($baseDirectory);
212        if ($handle === false) {
213            return false;
214        }
215        $baseDirectory = \rtrim($baseDirectory, \DIRECTORY_SEPARATOR) . \DIRECTORY_SEPARATOR;
216        $status = true;
217        while (($path = \readdir($handle)) !== false) {
218            if ($path[0] === '.') {
219                continue;
220            }
221            $path = $baseDirectory . $path;
222            if (\is_file($path)) {
223                $this->getContents($path);
224                continue;
225            }
226            if ( ! $this->deleteExpired($path)) {
227                $status = false;
228                break;
229            }
230            if (\scandir($path, \SCANDIR_SORT_ASCENDING) === ['.', '..'] && ! \rmdir($path)) {
231                $status = false;
232                break;
233            }
234        }
235        $this->closeDir($handle);
236        return $status;
237    }
238
239    protected function deleteAll(string $baseDirectory) : bool
240    {
241        $handle = $this->openDir($baseDirectory);
242        if ($handle === false) {
243            return false;
244        }
245        $baseDirectory = \rtrim($baseDirectory, \DIRECTORY_SEPARATOR) . \DIRECTORY_SEPARATOR;
246        $status = true;
247        while (($path = \readdir($handle)) !== false) {
248            if ($path[0] === '.') {
249                continue;
250            }
251            $path = $baseDirectory . $path;
252            if (\is_file($path)) {
253                if (\unlink($path)) {
254                    continue;
255                }
256                $this->log("Cache (files): File '{$path}' could not be deleted");
257                $status = false;
258                break;
259            }
260            if ( ! $this->deleteAll($path)) {
261                $status = false;
262                break;
263            }
264            if (\scandir($path, \SCANDIR_SORT_ASCENDING) === ['.', '..'] && ! \rmdir($path)) {
265                $status = false;
266                break;
267            }
268        }
269        $this->closeDir($handle);
270        return $status;
271    }
272
273    protected function deleteFile(string $filepath) : bool
274    {
275        if (\is_file($filepath)) {
276            $deleted = \unlink($filepath);
277            if ($deleted === false) {
278                $this->log("Cache (files): File '{$filepath}' could not be deleted");
279                return false;
280            }
281        }
282        return true;
283    }
284
285    /**
286     * @param string $dirpath
287     *
288     * @return false|resource
289     */
290    protected function openDir(string $dirpath)
291    {
292        $real = \realpath($dirpath);
293        if ($real === false) {
294            return false;
295        }
296        if ( ! \is_dir($real)) {
297            return false;
298        }
299        $real = \rtrim($real, \DIRECTORY_SEPARATOR) . \DIRECTORY_SEPARATOR;
300        if ( ! \str_starts_with($real, $this->configs['directory'])) {
301            return false;
302        }
303        return \opendir($real);
304    }
305
306    /**
307     * @param resource $resource
308     */
309    protected function closeDir($resource) : void
310    {
311        if (\is_resource($resource)) {
312            \closedir($resource);
313        }
314    }
315
316    #[Pure]
317    protected function renderFilepath(string $key) : string
318    {
319        $key = \md5($key);
320        return $this->baseDirectory .
321            $key[0] . $key[1] . \DIRECTORY_SEPARATOR .
322            $key;
323    }
324}