Completed
Push — master ( babca7...3cd5ce )
by Rasmus
05:59
created

FileCache::mkdir()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 11
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 2

Importance

Changes 0
Metric Value
dl 0
loc 11
ccs 7
cts 7
cp 1
rs 9.4285
c 0
b 0
f 0
cc 2
eloc 6
nc 2
nop 1
crap 2
1
<?php
2
3
namespace Kodus\Cache;
4
5
use DateInterval;
6
use FilesystemIterator;
7
use Generator;
8
use Psr\SimpleCache\CacheInterface;
9
use RecursiveDirectoryIterator;
10
use RecursiveIteratorIterator;
11
use Traversable;
12
13
/**
14
 * This is a simple, file-based cache implementation, which is bootstrapped by
15
 * the Core Provider as a default.
16
 *
17
 * Bootstrapping a more powerful cache for production scenarios is highly recommended.
18
 *
19
 * @link https://github.com/matthiasmullie/scrapbook/
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 14
    public function __construct($cache_path, $default_ttl, $dir_mode = 0775, $file_mode = 0664)
55
    {
56 14
        $this->default_ttl = $default_ttl;
57 14
        $this->dir_mode = $dir_mode;
58 14
        $this->file_mode = $file_mode;
59
60 14
        if (! file_exists($cache_path) && file_exists(dirname($cache_path))) {
61 14
            $this->mkdir($cache_path); // ensure that the parent path exists
62
        }
63
64 14
        $path = realpath($cache_path);
65
66 14
        if ($path === false) {
67
            throw new InvalidArgumentException("cache path does not exist: {$cache_path}");
68
        }
69
70 14
        if (! is_writable($path . DIRECTORY_SEPARATOR)) {
71
            throw new InvalidArgumentException("cache path is not writable: {$cache_path}");
72
        }
73
74 14
        $this->cache_path = $path;
75 14
    }
76
77 12
    public function get($key, $default = null)
78
    {
79 12
        $path = $this->getPath($key);
80
81 12
        $expires_at = @filemtime($path);
82
83 12
        if ($expires_at === false) {
84 11
            return $default; // file not found
85
        }
86
87 10
        if ($this->getTime() >= $expires_at) {
88 4
            @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 4
            return $default;
91
        }
92
93 9
        $data = @file_get_contents($path);
94
95 9
        if ($data === false) {
96
            return $default; // race condition: file not found
97
        }
98
99 9
        if ($data === 'b:0;') {
100
            return false; // because we can't otherwise distinguish a FALSE return-value from unserialize()
101
        }
102
103 9
        $value = @unserialize($data);
104
105 9
        if ($value === false) {
106
            return $default; // unserialize() failed
107
        }
108
109 9
        return $value;
110
    }
111
112 13
    public function set($key, $value, $ttl = null)
113
    {
114 13
        $path = $this->getPath($key);
115
116 13
        $dir = dirname($path);
117
118 13
        if (! file_exists($dir)) {
119
            // ensure that the parent path exists:
120 11
            $this->mkdir($dir);
121
        }
122
123 13
        $temp_path = $this->cache_path . DIRECTORY_SEPARATOR . uniqid('', true);
124
125 13
        if (is_int($ttl)) {
126 3
            $expires_at = $this->getTime() + $ttl;
127
        } elseif ($ttl instanceof DateInterval) {
128 1
            $expires_at = date_create_from_format("U", $this->getTime())->add($ttl)->getTimestamp();
129 9
        } elseif ($ttl === null) {
130 9
            $expires_at = $this->getTime() + $this->default_ttl;
131
        } else {
132
            throw new InvalidArgumentException("invalid TTL: " . print_r($ttl, true));
133
        }
134
135 13
        if (false === @file_put_contents($temp_path, serialize($value))) {
136
            return false;
137
        }
138
139 13
        if (false === @chmod($temp_path, $this->file_mode)) {
140
            return false;
141
        }
142
143 13
        if (@touch($temp_path, $expires_at) && @rename($temp_path, $path)) {
144 13
            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 2
    public function delete($key)
153
    {
154 2
        return @unlink($this->getPath($key));
155
    }
156
157 14
    public function clear()
158
    {
159 14
        $success = true;
160
161 14
        $paths = $this->listPaths();
162
163 14
        foreach ($paths as $path) {
164 9
            if (! unlink($path)) {
165 9
                $success = false;
166
            }
167
        }
168
169 14
        return $success;
170
    }
171
172 2
    public function getMultiple($keys, $default = null)
173
    {
174 2
        if (! is_array($keys) && ! $keys instanceof Traversable) {
175 1
            throw new InvalidArgumentException("keys must be either of type array or Traversable");
176
        }
177
178 2
        $values = [];
179
180 2
        foreach ($keys as $key) {
181 2
            $values[$key] = $this->get($key) ?: $default;
182
        }
183
184 2
        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...
185
    }
186
187 2
    public function setMultiple($values, $ttl = null)
188
    {
189 2
        if (! is_array($values) && ! $values instanceof Traversable) {
190 1
            throw new InvalidArgumentException("keys must be either of type array or Traversable");
191
        }
192
193 2
        $ok = true;
194
195 2
        foreach ($values as $key => $value) {
196 2
            $this->validateKey($key);
197 2
            $ok = $this->set($key, $value, $ttl) && $ok;
198
        }
199
200 2
        return $ok;
201
    }
202
203 1
    public function deleteMultiple($keys)
204
    {
205 1
        if (! is_array($keys) && ! $keys instanceof Traversable) {
206 1
            throw new InvalidArgumentException("keys must be either of type array or Traversable");
207
        }
208
209 1
        foreach ($keys as $key) {
210 1
            $this->validateKey($key);
211 1
            $this->delete($key);
212
        }
213 1
    }
214
215 1
    public function has($key)
216
    {
217 1
        return $this->get($key, $this) !== $this;
218
    }
219
220 2
    public function increment($key, $step = 1)
221
    {
222 2
        $path = $this->getPath($key);
223
224 2
        $dir = dirname($path);
225
226 2
        if (! file_exists($dir)) {
227 2
            $this->mkdir($dir); // ensure that the parent path exists
228
        }
229
230 2
        $lock_path = $dir . DIRECTORY_SEPARATOR . ".lock"; // allows max. 256 client locks at one time
231
232 2
        $lock_handle = fopen($lock_path, "w");
233
234 2
        flock($lock_handle, LOCK_EX);
235
236 2
        $value = $this->get($key, 0) + $step;
237
238 2
        $ok = $this->set($key, $value);
239
240 2
        flock($lock_handle, LOCK_UN);
241
242 2
        return $ok ? $value : false;
243
    }
244
245 1
    public function decrement($key, $step = 1)
246
    {
247 1
        return $this->increment($key, -$step);
248
    }
249
250
    /**
251
     * Clean up expired cache-files.
252
     *
253
     * This method is outside the scope of the PSR-16 cache concept, and is specific to
254
     * this implementation, being a file-cache.
255
     *
256
     * In scenarios with dynamic keys (such as Session IDs) you should call this method
257
     * periodically - for example from a scheduled daily cron-job.
258
     *
259
     * @return void
260
     */
261 1
    public function cleanExpired()
262
    {
263 1
        $now = $this->getTime();
264
265 1
        $paths = $this->listPaths();
266
267 1
        foreach ($paths as $path) {
268 1
            if ($now > filemtime($path)) {
269 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...
270
            }
271
        }
272 1
    }
273
274
    /**
275
     * For a given cache key, obtain the absolute file path
276
     *
277
     * @param string $key
278
     *
279
     * @return string absolute path to cache-file
280
     *
281
     * @throws InvalidArgumentException if the specified key contains a character reserved by PSR-16
282
     */
283 14
    protected function getPath($key)
284
    {
285 14
        $this->validateKey($key);
286
287 14
        $hash = hash("sha256", $key);
288
289 14
        return $this->cache_path
290 14
            . DIRECTORY_SEPARATOR
291 14
            . strtoupper($hash[0])
292 14
            . DIRECTORY_SEPARATOR
293 14
            . strtoupper($hash[1])
294 14
            . DIRECTORY_SEPARATOR
295 14
            . substr($hash, 2);
296
    }
297
298
    /**
299
     * @return int current timestamp
300
     */
301 14
    protected function getTime()
302
    {
303 14
        return time();
304
    }
305
306
    /**
307
     * @return Generator|string[]
308
     */
309 14
    protected function listPaths()
310
    {
311 14
        $iterator = new RecursiveDirectoryIterator(
312 14
            $this->cache_path,
313 14
            FilesystemIterator::CURRENT_AS_PATHNAME | FilesystemIterator::SKIP_DOTS
314
        );
315
316 14
        $iterator = new RecursiveIteratorIterator($iterator);
317
318 14
        foreach ($iterator as $path) {
319 9
            if (is_dir($path)) {
320
                continue; // ignore directories
321
            }
322
323 9
            yield $path;
324
        }
325 14
    }
326
327
    /**
328
     * @param string $key
329
     *
330
     * @throws InvalidArgumentException
331
     */
332 14
    protected function validateKey($key)
333
    {
334 14
        if (preg_match(self::PSR16_RESERVED, $key, $match) === 1) {
335 3
            throw new InvalidArgumentException("invalid character in key: {$match[0]}");
336
        }
337 14
    }
338
339
    /**
340
     * Recursively create directories and apply permission mask
341
     *
342
     * @param string $path absolute directory path
343
     */
344 14
    private function mkdir($path)
345
    {
346 14
        $parent_path = dirname($path);
347
348 14
        if (!file_exists($parent_path)) {
349 13
            $this->mkdir($parent_path); // recursively create parent dirs first
350
        }
351
352 14
        mkdir($path);
353 14
        chmod($path, $this->dir_mode);
354 14
    }
355
}
356