-
-
Notifications
You must be signed in to change notification settings - Fork 175
/
Copy pathFileCache.php
236 lines (203 loc) · 5.42 KB
/
FileCache.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
<?php
namespace Kirby\Cache;
use Kirby\Exception\Exception;
use Kirby\Filesystem\Dir;
use Kirby\Filesystem\F;
use Kirby\Toolkit\Str;
/**
* File System Cache Driver
*
* @package Kirby Cache
* @author Bastian Allgeier <[email protected]>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*/
class FileCache extends Cache
{
/**
* Full root including prefix
*/
protected string $root;
/**
* Sets all parameters which are needed for the file cache
*
* @param array $options 'root' (required)
* 'prefix' (default: none)
* 'extension' (file extension for cache files, default: none)
*/
public function __construct(
array $options
) {
parent::__construct([
'root' => null,
'prefix' => null,
'extension' => null,
...$options
]);
// build the full root including prefix
$this->root = $this->options['root'];
if (empty($this->options['prefix']) === false) {
$this->root .= '/' . $this->options['prefix'];
}
// try to create the directory
Dir::make($this->root, true);
}
/**
* Checks when the cache has been created;
* returns the creation timestamp on success
* and false if the item does not exist
*/
public function created(string $key): int|false
{
// use the modification timestamp
// as indicator when the cache has been created/overwritten
clearstatcache();
// get the file for this cache key
$file = $this->file($key);
return file_exists($file) ? filemtime($file) : false;
}
/**
* Returns whether the cache is ready to
* store values
*/
public function enabled(): bool
{
return is_writable($this->root) === true;
}
/**
* Returns the full path to a file for a given key
*/
protected function file(string $key): string
{
// strip out invalid characters in each path segment
// split by slash or backslash
$keyParts = [];
foreach (preg_split('#([\/\\\\])#', $key, 0, PREG_SPLIT_DELIM_CAPTURE) as $part) {
switch ($part) {
case '/':
// forward slashes don't need special treatment
break;
case '\\':
// backslashes get their own marker in the path
// to differentiate the cache key from one with forward slashes
$keyParts[] = '_backslash';
break;
case '':
// empty part means two slashes in a row;
// special marker like for backslashes
$keyParts[] = '_empty';
break;
default:
// an actual path segment:
// check if the segment only contains safe characters;
// underscores are *not* safe to guarantee uniqueness
// as they are used in the special cases
if (preg_match('/^[a-zA-Z0-9-]+$/', $part) === 1) {
$keyParts[] = $part;
} else {
$keyParts[] = Str::slug($part) . '_' . sha1($part);
}
}
}
$file = $this->root . '/' . implode('/', $keyParts);
if (isset($this->options['extension'])) {
return $file . '.' . $this->options['extension'];
}
return $file;
}
/**
* Flushes the entire cache and returns
* whether the operation was successful
*/
public function flush(): bool
{
if (
Dir::remove($this->root) === true &&
Dir::make($this->root) === true
) {
return true;
}
return false; // @codeCoverageIgnore
}
/**
* Whether the cache has any entry,
* irrespective whether the entries have expired or not
*/
public function isEmpty(): bool
{
return Dir::isEmpty($this->root);
}
/**
* Removes an item from the cache and returns
* whether the operation was successful
*/
public function remove(string $key): bool
{
$file = $this->file($key);
if (is_file($file) === true && F::remove($file) === true) {
$this->removeEmptyDirectories(dirname($file));
return true;
}
return false;
}
/**
* Removes empty directories safely by checking each directory
* up to the root directory
*/
protected function removeEmptyDirectories(string $dir): void
{
try {
// ensure the path doesn't end with a slash for the next comparison
$dir = rtrim($dir, '/\/');
// checks all directory segments until reaching the root directory
while (Str::startsWith($dir, $this->root()) === true && $dir !== $this->root()) {
$files = scandir($dir);
if ($files === false) {
$files = []; // @codeCoverageIgnore
}
$files = array_diff($files, ['.', '..']);
if (empty($files) === true && Dir::remove($dir) === true) {
// continue with the next level up
$dir = dirname($dir);
} else {
// no need to continue with the next level up as `$dir` was not deleted
break;
}
}
} catch (Exception) { // @codeCoverageIgnore
// silently stops the process
}
}
/**
* Internal method to retrieve the raw cache value;
* needs to return a Value object or null if not found
*/
public function retrieve(string $key): Value|null
{
$file = $this->file($key);
$value = F::read($file);
return $value ? Value::fromJson($value) : null;
}
/**
* Returns the full root including prefix
*/
public function root(): string
{
return $this->root;
}
/**
* Writes an item to the cache for a given number of minutes and
* returns whether the operation was successful
*
* <code>
* // put an item in the cache for 15 minutes
* $cache->set('value', 'my value', 15);
* </code>
*/
public function set(string $key, $value, int $minutes = 0): bool
{
$file = $this->file($key);
return F::write($file, (new Value($value, $minutes))->toJson());
}
}