Completed
Push — master ( 5a9da4...65f015 )
by Rasmus
14:05 queued 12s
created

FileCache::getPath()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 14

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 10
CRAP Score 1

Importance

Changes 0
Metric Value
dl 0
loc 14
ccs 10
cts 10
cp 1
rs 9.7998
c 0
b 0
f 0
cc 1
nc 1
nop 1
crap 1
1
<?php
2
3
namespace Kodus\Cache;
4
5
use DateInterval;
6
use function file_exists;
7
use FilesystemIterator;
8
use Generator;
9
use function gettype;
10
use function is_int;
11
use Psr\SimpleCache\CacheInterface;
12
use RecursiveDirectoryIterator;
13
use RecursiveIteratorIterator;
14
use Traversable;
15
16
/**
17
 * This class implements a simple, file-based cache.
18
 *
19
 * Make sure your schedule an e.g. nightly call to {@see cleanExpired()}.
20
 */
21
class FileCache implements CacheInterface
22
{
23
    /**
24
     * @var string control characters for keys, reserved by PSR-16
25
     */
26
    const PSR16_RESERVED = '/\{|\}|\(|\)|\/|\\\\|\@|\:/u';
27
28
    /**
29
     * @var string
30
     */
31
    private $cache_path;
32
33
    /**
34
     * @var int
35
     */
36
    private $default_ttl;
37
38
    /**
39
     * @var int
40
     */
41
    private $dir_mode;
42
43
    /**
44
     * @var int
45
     */
46
    private $file_mode;
47
48
    /**
49
     * @param string $cache_path  absolute root path of cache-file folder
50
     * @param int    $default_ttl default time-to-live (in seconds)
51
     * @param int    $dir_mode    permission mode for created dirs
52
     * @param int    $file_mode   permission mode for created files
53
     */
54 207
    public function __construct($cache_path, $default_ttl, $dir_mode = 0775, $file_mode = 0664)
55
    {
56 207
        $this->default_ttl = $default_ttl;
57 207
        $this->dir_mode = $dir_mode;
58 207
        $this->file_mode = $file_mode;
59
60 207
        if (! file_exists($cache_path) && file_exists(dirname($cache_path))) {
61 207
            $this->mkdir($cache_path); // ensure that the parent path exists
62
        }
63
64 207
        $path = realpath($cache_path);
65
66 207
        if ($path === false) {
67
            throw new InvalidArgumentException("cache path does not exist: {$cache_path}");
68
        }
69
70 207
        if (! is_writable($path . DIRECTORY_SEPARATOR)) {
71
            throw new InvalidArgumentException("cache path is not writable: {$cache_path}");
72
        }
73
74 207
        $this->cache_path = $path;
75 207
    }
76
77 111
    public function get($key, $default = null)
78
    {
79 111
        $path = $this->getPath($key);
80
81 75
        $expires_at = @filemtime($path);
82
83 75
        if ($expires_at === false) {
84 40
            return $default; // file not found
85
        }
86
87 50
        if ($this->getTime() >= $expires_at) {
88 8
            @unlink($path); // file expired
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition here. This can introduce security issues, and is generally not recommended.

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
89
90 8
            return $default;
91
        }
92
93 47
        $data = @file_get_contents($path);
94
95 47
        if ($data === false) {
96
            return $default; // race condition: file not found
97
        }
98
99 47
        if ($data === 'b:0;') {
100 1
            return false; // because we can't otherwise distinguish a FALSE return-value from unserialize()
101
        }
102
103 46
        $value = @unserialize($data);
104
105 46
        if ($value === false) {
106
            return $default; // unserialize() failed
107
        }
108
109 46
        return $value;
110
    }
111
112 112
    public function set($key, $value, $ttl = null)
113
    {
114 112
        $path = $this->getPath($key);
115
116 94
        $dir = dirname($path);
117
118 94
        if (! file_exists($dir)) {
119
            // ensure that the parent path exists:
120 92
            $this->mkdir($dir);
121
        }
122
123 94
        $temp_path = $this->cache_path . DIRECTORY_SEPARATOR . uniqid('', true);
124
125 94
        if (is_int($ttl)) {
126 7
            $expires_at = $this->getTime() + $ttl;
127 90
        } elseif ($ttl instanceof DateInterval) {
128 3
            $expires_at = date_create_from_format("U", $this->getTime())->add($ttl)->getTimestamp();
129 87
        } elseif ($ttl === null) {
130 67
            $expires_at = $this->getTime() + $this->default_ttl;
131
        } else {
132 20
            throw new InvalidArgumentException("invalid TTL: " . print_r($ttl, true));
133
        }
134
135 74
        if (false === @file_put_contents($temp_path, serialize($value))) {
136
            return false;
137
        }
138
139 74
        if (false === @chmod($temp_path, $this->file_mode)) {
140
            return false;
141
        }
142
143 74
        if (@touch($temp_path, $expires_at) && @rename($temp_path, $path)) {
144 74
            return true;
145
        }
146
147
        @unlink($temp_path);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition here. This can introduce security issues, and is generally not recommended.

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
148
149
        return false;
150
    }
151
152 42
    public function delete($key)
153
    {
154 42
        $this->validateKey($key);
155
156 24
        $path = $this->getPath($key);
157
158 24
        return !file_exists($path) || @unlink($path);
159
    }
160
161 207
    public function clear()
162
    {
163 207
        $success = true;
164
165 207
        $paths = $this->listPaths();
166
167 207
        foreach ($paths as $path) {
168 62
            if (! unlink($path)) {
169 62
                $success = false;
170
            }
171
        }
172
173 207
        return $success;
174
    }
175
176 32 View Code Duplication
    public function getMultiple($keys, $default = null)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
177
    {
178 32
        if (! is_array($keys) && ! $keys instanceof Traversable) {
179 2
            throw new InvalidArgumentException("keys must be either of type array or Traversable");
180
        }
181
182 31
        $values = [];
183
184 31
        foreach ($keys as $key) {
185 31
            $values[$key] = $this->get($key) ?: $default;
186
        }
187
188 13
        return $values;
0 ignored issues
show
Bug Best Practice introduced by
The return type of return $values; (array) is incompatible with the return type declared by the interface Psr\SimpleCache\CacheInterface::getMultiple of type Psr\SimpleCache\iterable.

If you return a value from a function or method, it should be a sub-type of the type that is given by the parent type f.e. an interface, or abstract method. This is more formally defined by the Lizkov substitution principle, and guarantees that classes that depend on the parent type can use any instance of a child type interchangably. This principle also belongs to the SOLID principles for object oriented design.

Let’s take a look at an example:

class Author {
    private $name;

    public function __construct($name) {
        $this->name = $name;
    }

    public function getName() {
        return $this->name;
    }
}

abstract class Post {
    public function getAuthor() {
        return 'Johannes';
    }
}

class BlogPost extends Post {
    public function getAuthor() {
        return new Author('Johannes');
    }
}

class ForumPost extends Post { /* ... */ }

function my_function(Post $post) {
    echo strtoupper($post->getAuthor());
}

Our function my_function expects a Post object, and outputs the author of the post. The base class Post returns a simple string and outputting a simple string will work just fine. However, the child class BlogPost which is a sub-type of Post instead decided to return an object, and is therefore violating the SOLID principles. If a BlogPost were passed to my_function, PHP would not complain, but ultimately fail when executing the strtoupper call in its body.

Loading history...
189
    }
190
191 44
    public function setMultiple($values, $ttl = null)
192
    {
193 44
        if (! is_array($values) && ! $values instanceof Traversable) {
194 2
            throw new InvalidArgumentException("keys must be either of type array or Traversable");
195
        }
196
197 43
        $ok = true;
198
199 43
        foreach ($values as $key => $value) {
200 43
            if (is_int($key)) {
201 1
                $key = (string) $key;
202
            }
203
204 43
            $this->validateKey($key);
205
206 43
            $ok = $this->set($key, $value, $ttl) && $ok;
207
        }
208
209 16
        return $ok;
210
    }
211
212 22 View Code Duplication
    public function deleteMultiple($keys)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
213
    {
214 22
        if (! is_array($keys) && ! $keys instanceof Traversable) {
215 2
            throw new InvalidArgumentException("keys must be either of type array or Traversable");
216
        }
217
218 21
        $ok = true;
219
220 21
        foreach ($keys as $key) {
221 21
            $this->validateKey($key);
222
223 21
            $ok = $ok && $this->delete($key);
224
        }
225
226 3
        return $ok;
227
    }
228
229 23
    public function has($key)
230
    {
231 23
        return $this->get($key, $this) !== $this;
232
    }
233
234 2
    public function increment($key, $step = 1)
235
    {
236 2
        $path = $this->getPath($key);
237
238 2
        $dir = dirname($path);
239
240 2
        if (! file_exists($dir)) {
241 2
            $this->mkdir($dir); // ensure that the parent path exists
242
        }
243
244 2
        $lock_path = $dir . DIRECTORY_SEPARATOR . ".lock"; // allows max. 256 client locks at one time
245
246 2
        $lock_handle = fopen($lock_path, "w");
247
248 2
        flock($lock_handle, LOCK_EX);
249
250 2
        $value = $this->get($key, 0) + $step;
251
252 2
        $ok = $this->set($key, $value);
253
254 2
        flock($lock_handle, LOCK_UN);
255
256 2
        return $ok ? $value : false;
257
    }
258
259 1
    public function decrement($key, $step = 1)
260
    {
261 1
        return $this->increment($key, -$step);
262
    }
263
264
    /**
265
     * Clean up expired cache-files.
266
     *
267
     * This method is outside the scope of the PSR-16 cache concept, and is specific to
268
     * this implementation, being a file-cache.
269
     *
270
     * In scenarios with dynamic keys (such as Session IDs) you should call this method
271
     * periodically - for example from a scheduled daily cron-job.
272
     *
273
     * @return void
274
     */
275 1
    public function cleanExpired()
276
    {
277 1
        $now = $this->getTime();
278
279 1
        $paths = $this->listPaths();
280
281 1
        foreach ($paths as $path) {
282 1
            if ($now > filemtime($path)) {
283 1
                @unlink($path);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition here. This can introduce security issues, and is generally not recommended.

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
284
            }
285
        }
286 1
    }
287
288
    /**
289
     * For a given cache key, obtain the absolute file path
290
     *
291
     * @param string $key
292
     *
293
     * @return string absolute path to cache-file
294
     *
295
     * @throws InvalidArgumentException if the specified key contains a character reserved by PSR-16
296
     */
297 186
    protected function getPath($key)
298
    {
299 186
        $this->validateKey($key);
300
301 132
        $hash = hash("sha256", $key);
302
303 132
        return $this->cache_path
304 132
            . DIRECTORY_SEPARATOR
305 132
            . strtoupper($hash[0])
306 132
            . DIRECTORY_SEPARATOR
307 132
            . strtoupper($hash[1])
308 132
            . DIRECTORY_SEPARATOR
309 132
            . substr($hash, 2);
310
    }
311
312
    /**
313
     * @return int current timestamp
314
     */
315 75
    protected function getTime()
316
    {
317 75
        return time();
318
    }
319
320
    /**
321
     * @return Generator|string[]
322
     */
323 207
    protected function listPaths()
324
    {
325 207
        $iterator = new RecursiveDirectoryIterator(
326 207
            $this->cache_path,
327 207
            FilesystemIterator::CURRENT_AS_PATHNAME | FilesystemIterator::SKIP_DOTS
328
        );
329
330 207
        $iterator = new RecursiveIteratorIterator($iterator);
331
332 207
        foreach ($iterator as $path) {
333 62
            if (is_dir($path)) {
334
                continue; // ignore directories
335
            }
336
337 62
            yield $path;
338
        }
339 207
    }
340
341
    /**
342
     * @param string $key
343
     *
344
     * @throws InvalidArgumentException
345
     */
346 204
    protected function validateKey($key)
347
    {
348 204
        if (! is_string($key)) {
349 48
            $type = is_object($key) ? get_class($key) : gettype($key);
350
351 48
            throw new InvalidArgumentException("invalid key type: {$type} given");
352
        }
353
354 176
        if ($key === "") {
355 7
            throw new InvalidArgumentException("invalid key: empty string given");
356
        }
357
358 172
        if ($key === null) {
359
            throw new InvalidArgumentException("invalid key: null given");
360
        }
361
362 172
        if (preg_match(self::PSR16_RESERVED, $key, $match) === 1) {
363 73
            throw new InvalidArgumentException("invalid character in key: {$match[0]}");
364
        }
365 132
    }
366
367
    /**
368
     * Recursively create directories and apply permission mask
369
     *
370
     * @param string $path absolute directory path
371
     */
372 207
    private function mkdir($path)
373
    {
374 207
        $parent_path = dirname($path);
375
376 207
        if (!file_exists($parent_path)) {
377 94
            $this->mkdir($parent_path); // recursively create parent dirs first
378
        }
379
380 207
        mkdir($path);
381 207
        chmod($path, $this->dir_mode);
382 207
    }
383
}
384