Issues (13)

src/FileCache.php (6 issues)

1
<?php
2
3
declare(strict_types=1);
4
5
namespace Kodus\Cache;
6
7
use DateInterval;
8
use FilesystemIterator;
9
use Generator;
10
use Psr\SimpleCache\CacheInterface;
11
use RecursiveDirectoryIterator;
12
use RecursiveIteratorIterator;
13
14
/**
15
 * This class implements a simple, file-based cache.
16
 *
17
 * Make sure your schedule an e.g. nightly call to {@see cleanExpired()}.
18
 */
19
class FileCache implements CacheInterface
20
{
21
    /**
22
     * @var string control characters for keys, reserved by PSR-16
23
     */
24
    const PSR16_RESERVED = '/\{|\}|\(|\)|\/|\\\\|\@|\:/u';
25
26
    /**
27
     * @var string
28
     */
29
    private $cache_path;
30
31
    /**
32
     * @var int
33
     */
34
    private $default_ttl;
35
36
    /**
37
     * @var int
38
     */
39
    private $dir_mode;
40
41
    /**
42
     * @var int
43
     */
44
    private $file_mode;
45
46
    /**
47
     * @param string $cache_path  absolute root path of cache-file folder
48
     * @param int    $default_ttl default time-to-live (in seconds)
49
     * @param int    $dir_mode    permission mode for created dirs
50
     * @param int    $file_mode   permission mode for created files
51
     *
52
     * @throws InvalidArgumentException
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(string $key, mixed $default = null): mixed
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 for unlink(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

88
            /** @scrutinizer ignore-unhandled */ @unlink($path); // file expired

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(string $key, mixed $value, DateInterval|int|null $ttl = null): bool
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", (string) $this->getTime())->add($ttl)->getTimestamp();
129 87
        } elseif ($ttl === null) {
0 ignored issues
show
The condition $ttl === null is always true.
Loading history...
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 for unlink(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

147
        /** @scrutinizer ignore-unhandled */ @unlink($temp_path);

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(string $key): bool
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(): bool
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
    public function getMultiple(iterable $keys, mixed $default = null): iterable
177
    {
178 32
        $values = [];
179 2
180
        foreach ($keys as $key) {
181
            $values[$key] = $this->get($key) ?: $default;
182 31
        }
183
184 31
        return $values;
185 31
    }
186
187
    public function setMultiple(iterable $values, DateInterval|int|null $ttl = null): bool
188 13
    {
189
        $ok = true;
190
191 44
        foreach ($values as $key => $value) {
192
            if (is_int($key)) {
193 44
                $key = (string) $key;
194 2
            }
195
196
            $this->validateKey($key);
197 43
198
            $ok = $this->set($key, $value, $ttl) && $ok;
199 43
        }
200 43
201 1
        return $ok;
202
    }
203
204 43
    public function deleteMultiple(iterable $keys): bool
205
    {
206 43
        $ok = true;
207
208
        foreach ($keys as $key) {
209 16
            $this->validateKey($key);
210
211
            $ok = $ok && $this->delete($key);
212 22
        }
213
214 22
        return $ok;
215 2
    }
216
217
    public function has(string $key): bool
218 21
    {
219
        return $this->get($key, $this) !== $this;
220 21
    }
221 21
222
    public function increment($key, $step = 1)
223 21
    {
224
        $path = $this->getPath($key);
225
226 3
        $dir = dirname($path);
227
228
        if (! file_exists($dir)) {
229 23
            $this->mkdir($dir); // ensure that the parent path exists
230
        }
231 23
232
        $lock_path = $dir . DIRECTORY_SEPARATOR . ".lock"; // allows max. 256 client locks at one time
233
234 2
        $lock_handle = fopen($lock_path, "w");
235
236 2
        flock($lock_handle, LOCK_EX);
237
238 2
        $value = $this->get($key, 0) + $step;
239
240 2
        $ok = $this->set($key, $value);
241 2
242
        flock($lock_handle, LOCK_UN);
243
244 2
        return $ok ? $value : false;
245
    }
246 2
247
    public function decrement($key, $step = 1)
248 2
    {
249
        return $this->increment($key, -$step);
250 2
    }
251
252 2
    /**
253
     * Clean up expired cache-files.
254 2
     *
255
     * This method is outside the scope of the PSR-16 cache concept, and is specific to
256 2
     * this implementation, being a file-cache.
257
     *
258
     * In scenarios with dynamic keys (such as Session IDs) you should call this method
259 1
     * periodically - for example from a scheduled daily cron-job.
260
     *
261 1
     * @return void
262
     */
263
    public function cleanExpired()
264
    {
265
        $now = $this->getTime();
266
267
        $paths = $this->listPaths();
268
269
        foreach ($paths as $path) {
270
            if ($now > filemtime($path)) {
271
                @unlink($path);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for unlink(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

271
                /** @scrutinizer ignore-unhandled */ @unlink($path);

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