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 |
|
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
introduced
by
![]() |
|||
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); |
||
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); |
||
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
|
|||
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
|
|||
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 |