Passed
Pull Request — master (#2148)
by Arnaud
11:21 queued 04:41
created

Cache::get()   B

Complexity

Conditions 7
Paths 13

Size

Total Lines 29
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 10.1359

Importance

Changes 5
Bugs 0 Features 1
Metric Value
cc 7
eloc 15
c 5
b 0
f 1
nc 13
nop 2
dl 0
loc 29
ccs 9
cts 15
cp 0.6
crap 10.1359
rs 8.8333
1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * This file is part of Cecil.
7
 *
8
 * Copyright (c) Arnaud Ligny <[email protected]>
9
 *
10
 * For the full copyright and license information, please view the LICENSE
11
 * file that was distributed with this source code.
12
 */
13
14
namespace Cecil\Assets;
15
16
use Cecil\Builder;
17
use Cecil\Collection\Page\Page;
18
use Cecil\Exception\RuntimeException;
19
use Cecil\Util;
20
use Psr\SimpleCache\CacheInterface;
21
22
class Cache implements CacheInterface
23
{
24
    /** @var Builder */
25
    protected $builder;
26
27
    /** @var string */
28
    protected $cacheDir;
29
30
    /** @var int */
31
    protected $duration;
32
33 1
    public function __construct(Builder $builder, string $pool = '')
34
    {
35 1
        $this->builder = $builder;
36 1
        $this->cacheDir = Util::joinFile($builder->getConfig()->getCachePath(), $pool);
37 1
        $this->duration = 31536000; // 1 year
38
    }
39
40
    /**
41
     * {@inheritdoc}
42
     */
43 1
    public function has($key): bool
44
    {
45 1
        $key = $this->sanitizeKey($key);
46 1
        if (!Util\File::getFS()->exists($this->getFilePathname($key))) {
47 1
            return false;
48
        }
49
50 1
        return true;
51
    }
52
53
    /**
54
     * {@inheritdoc}
55
     */
56 1
    public function get($key, $default = null): mixed
57
    {
58
        try {
59 1
            $key = $this->sanitizeKey($key);
60
            // return default value if file doesn't exists
61 1
            if (false === $content = Util\File::fileGetContents($this->getFilePathname($key))) {
62
                return $default;
63
            }
64
            // unserialize data
65 1
            $data = unserialize($content);
66
            // check expiration
67 1
            if ($data['expiration'] <= time()) {
68
                $this->delete($key);
69
70
                return $default;
71
            }
72
            // get content from dedicated file
73 1
            if (\is_array($data['value']) && isset($data['value']['path'])) {
74 1
                if (false !== $content = Util\File::fileGetContents($this->getContentFilePathname($data['value']['path']))) {
75 1
                    $data['value']['content'] = $content;
76
                }
77
            }
78
        } catch (\Exception $e) {
79
            $this->builder->getLogger()->error($e->getMessage());
80
81
            return $default;
82
        }
83
84 1
        return $data['value'];
85
    }
86
87
    /**
88
     * {@inheritdoc}
89
     */
90 1
    public function set($key, $value, $ttl = null): bool
91
    {
92
        try {
93 1
            $key = $this->sanitizeKey($key);
94 1
            $this->prune($key);
95
            // put file content in a dedicated file
96 1
            if (\is_array($value) && !empty($value['content']) && !empty($value['path'])) {
97 1
                Util\File::getFS()->dumpFile($this->getContentFilePathname($value['path']), $value['content']);
98 1
                unset($value['content']);
99
            }
100
            // serialize data
101 1
            $data = serialize([
102 1
                'value'      => $value,
103 1
                'expiration' => time() + $this->duration($ttl),
104 1
            ]);
105 1
            Util\File::getFS()->dumpFile($this->getFilePathname($key), $data);
106
        } catch (\Exception $e) {
107
            $this->builder->getLogger()->error($e->getMessage());
108
109
            return false;
110
        }
111
112 1
        return true;
113
    }
114
115
    /**
116
     * {@inheritdoc}
117
     */
118
    public function delete($key): bool
119
    {
120
        try {
121
            $key = $this->sanitizeKey($key);
122
            Util\File::getFS()->remove($this->getFilePathname($key));
123
            $this->prune($key);
124
        } catch (\Exception $e) {
125
            $this->builder->getLogger()->error($e->getMessage());
126
127
            return false;
128
        }
129
130
        return true;
131
    }
132
133
    /**
134
     * {@inheritdoc}
135
     */
136
    public function clear(): bool
137
    {
138
        try {
139
            Util\File::getFS()->remove($this->cacheDir);
140
        } catch (\Exception $e) {
141
            $this->builder->getLogger()->error($e->getMessage());
142
143
            return false;
144
        }
145
146
        return true;
147
    }
148
149
    /**
150
     * {@inheritdoc}
151
     */
152
    public function getMultiple($keys, $default = null): iterable
153
    {
154
        throw new \Exception(\sprintf('%s::%s not yet implemented.', __CLASS__, __FUNCTION__));
155
    }
156
157
    /**
158
     * {@inheritdoc}
159
     */
160
    public function setMultiple($values, $ttl = null): bool
161
    {
162
        throw new \Exception(\sprintf('%s::%s not yet implemented.', __CLASS__, __FUNCTION__));
163
    }
164
165
    /**
166
     * {@inheritdoc}
167
     */
168
    public function deleteMultiple($keys): bool
169
    {
170
        throw new \Exception(\sprintf('%s::%s not yet implemented.', __CLASS__, __FUNCTION__));
171
    }
172
173
    /**
174
     * Creates key with the MD5 hash of a string.
175
     */
176 1
    public function createKeyFromString(string $value, ?string $suffix = null): string
177
    {
178 1
        return \sprintf('%s%s__%s', hash('md5', $value), ($suffix ? '_' . $suffix : ''), $this->builder->getVersion());
179
    }
180
181
    /**
182
     * Creates key from a file: "$relativePath__MD5".
183
     *
184
     * @throws RuntimeException
185
     */
186
    public function createKeyFromPath(string $path, string $relativePath): string
187
    {
188
        if (false === $content = Util\File::fileGetContents($path)) {
189
            throw new RuntimeException(\sprintf('Can\'t create cache key for "%s".', $path));
190
        }
191
192
        return $this->sanitizeKey(\sprintf('%s__%s', $relativePath, $this->createKeyFromString($content)));
193
    }
194
195
    /**
196
     * Creates key from an Asset: "$filename_$ext_$tags__VERSION__MD5".
197
     */
198 1
    public function createKeyFromAsset(Asset $asset, ?array $tags = null): string
199
    {
200 1
        $tags = implode('_', $tags ?? []);
201
202 1
        return $this->sanitizeKey(\sprintf(
203 1
            '%s%s%s__%s',
204 1
            $asset['filename'],
205 1
            "_{$asset['ext']}",
206 1
            $tags ? "_$tags" : '',
207 1
            $this->createKeyFromString($asset['content'] ?? '')
208 1
        ));
209
    }
210
211
    /**
212
     * Clear cache by pattern.
213
     */
214
    public function clearByPattern(string $pattern): int
215
    {
216
        try {
217
            if (!Util\File::getFS()->exists($this->cacheDir)) {
218
                throw new RuntimeException(\sprintf('The cache directory "%s" does not exists.', $this->cacheDir));
219
            }
220
            $fileCount = 0;
221
            $iterator = new \RecursiveIteratorIterator(
222
                new \RecursiveDirectoryIterator($this->cacheDir),
223
                \RecursiveIteratorIterator::SELF_FIRST
224
            );
225
            foreach ($iterator as $file) {
226
                if ($file->isFile()) {
227
                    if (preg_match('/' . $pattern . '/i', $file->getPathname())) {
228
                        Util\File::getFS()->remove($file->getPathname());
229
                        $fileCount++;
230
                        $this->builder->getLogger()->debug(\sprintf('Cache file "%s" removed', Util\File::getFS()->makePathRelative($file->getPathname(), $this->builder->getConfig()->getCachePath())));
231
                    }
232
                }
233
            }
234
        } catch (\Exception $e) {
235
            $this->builder->getLogger()->error($e->getMessage());
236
237
            return 0;
238
        }
239
240
        return $fileCount;
241
    }
242
243
    /**
244
     * Prepares and validate $key.
245
     */
246 1
    public function sanitizeKey(string $key): string
247
    {
248 1
        $key = str_replace(['https://', 'http://'], '', $key); // remove protocol (if URL)
249 1
        $key = Page::slugify($key);                            // slugify
250 1
        $key = trim($key, '/');                                // remove leading/trailing slashes
251 1
        $key = str_replace(['\\', '/'], ['-', '-'], $key);     // replace slashes by hyphens
252 1
        $key = substr($key, 0, 200);                           // truncate to 200 characters (NTFS filename length limit is 255 characters)
253
254 1
        return $key;
255
    }
256
257
    /**
258
     * Returns cache content file pathname from path.
259
     */
260 1
    public function getContentFilePathname(string $path): string
261
    {
262 1
        return Util::joinFile($this->builder->getConfig()->getCacheAssetsFilesPath(), $path);
263
    }
264
265
    /**
266
     * Returns cache file pathname from key.
267
     */
268 1
    private function getFilePathname(string $key): string
269
    {
270 1
        return Util::joinFile($this->cacheDir, \sprintf('%s.ser', $key));
271
    }
272
273
    /**
274
     * Removes previous cache files.
275
     */
276 1
    private function prune(string $key): bool
277
    {
278
        try {
279 1
            $keyAsArray = explode('__', $this->sanitizeKey($key));
280
            // if 3 parts (with hash), remove all files with the same first part
281
            // pattern: `path_tag__hash__version`
282 1
            if (!empty($keyAsArray[0]) && \count($keyAsArray) == 3) {
283 1
                $pattern = Util::joinFile($this->cacheDir, $keyAsArray[0]) . '*';
284 1
                foreach (glob($pattern) ?: [] as $filename) {
285
                    Util\File::getFS()->remove($filename);
286
                    $this->builder->getLogger()->debug(\sprintf('Cache file "%s" removed', Util\File::getFS()->makePathRelative($filename, $this->builder->getConfig()->getCachePath())));
287
                }
288
            }
289
        } catch (\Exception $e) {
290
            $this->builder->getLogger()->error($e->getMessage());
291
292
            return false;
293
        }
294
295 1
        return true;
296
    }
297
298
    /**
299
     * Convert the various expressions of a TTL value into duration in seconds.
300
     */
301 1
    protected function duration(\DateInterval|int|null $ttl): int
302
    {
303 1
        if ($ttl === null) {
304 1
            return $this->duration;
305
        }
306
        if (\is_int($ttl)) {
307
            return $ttl;
308
        }
309
        if ($ttl instanceof \DateInterval) {
310
            return (int)$ttl->format('%s');
311
        }
312
313
        throw new \InvalidArgumentException('TTL values must be one of null, int, \DateInterval');
314
    }
315
}
316