Passed
Push — master ( 84c1d0...0ba0dc )
by Arnaud
06:31
created

Cache::createKeyFromAsset()   B

Complexity

Conditions 10
Paths 4

Size

Total Lines 32
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 19
CRAP Score 10.0125

Importance

Changes 0
Metric Value
cc 10
eloc 19
c 0
b 0
f 0
nc 4
nop 2
dl 0
loc 32
ccs 19
cts 20
cp 0.95
crap 10.0125
rs 7.6666

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
/**
4
 * This file is part of Cecil.
5
 *
6
 * (c) Arnaud Ligny <[email protected]>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11
12
declare(strict_types=1);
13
14
namespace Cecil;
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
/**
23
 * Cache class.
24
 *
25
 * Provides methods to manage cache files for assets, pages, and other data.
26
 */
27
class Cache implements CacheInterface
28
{
29
    /** @var Builder */
30
    protected $builder;
31
32
    /** @var string */
33
    protected $cacheDir;
34
35 1
    public function __construct(Builder $builder, string $pool = '')
36
    {
37 1
        $this->builder = $builder;
38 1
        $this->cacheDir = Util::joinFile($builder->getConfig()->getCachePath(), $pool);
39
    }
40
41
    /**
42
     * {@inheritdoc}
43
     */
44 1
    public function set($key, $value, $ttl = null): bool
45
    {
46
        try {
47 1
            $key = self::sanitizeKey($key);
48 1
            $this->prune($key);
49
            // put file content in a dedicated file
50 1
            if (\is_array($value) && !empty($value['content']) && !empty($value['path'])) {
51 1
                Util\File::getFS()->dumpFile($this->getContentFilePathname($value['path']), $value['content']);
52 1
                unset($value['content']);
53
            }
54
            // serialize data
55 1
            $data = serialize([
56 1
                'value'      => $value,
57 1
                'expiration' => $ttl === null ? null : time() + $this->duration($ttl),
58 1
            ]);
59 1
            Util\File::getFS()->dumpFile($this->getFilePathname($key), $data);
60
        } catch (\Exception $e) {
61
            $this->builder->getLogger()->error($e->getMessage());
62
63
            return false;
64
        }
65
66 1
        return true;
67
    }
68
69
    /**
70
     * {@inheritdoc}
71
     */
72 1
    public function has($key): bool
73
    {
74 1
        $key = self::sanitizeKey($key);
75 1
        if (!Util\File::getFS()->exists($this->getFilePathname($key))) {
76 1
            return false;
77
        }
78
79 1
        return true;
80
    }
81
82
    /**
83
     * {@inheritdoc}
84
     */
85 1
    public function get($key, $default = null): mixed
86
    {
87
        try {
88 1
            $key = self::sanitizeKey($key);
89
            // return default value if file doesn't exists
90 1
            if (false === $content = Util\File::fileGetContents($this->getFilePathname($key))) {
91
                return $default;
92
            }
93
            // unserialize data
94 1
            $data = unserialize($content);
95
            // check expiration
96 1
            if ($data['expiration'] !== null && $data['expiration'] <= time()) {
97
                $this->builder->getLogger()->debug(\sprintf('Cache expired: "%s"', $key));
98
                // remove expired cache
99
                if ($this->delete($key)) {
100
                    // remove content file if exists
101
                    if (!empty($data['value']['path'])) {
102
                        $this->deleteContentFile($data['value']['path']);
103
                    }
104
                }
105
106
                return $default;
107
            }
108
            // get content from dedicated file
109 1
            if (\is_array($data['value']) && isset($data['value']['path'])) {
110 1
                if (false !== $content = Util\File::fileGetContents($this->getContentFilePathname($data['value']['path']))) {
111 1
                    $data['value']['content'] = $content;
112
                }
113
            }
114
        } catch (\Exception $e) {
115
            $this->builder->getLogger()->error($e->getMessage());
116
117
            return $default;
118
        }
119
120 1
        return $data['value'];
121
    }
122
123
    /**
124
     * {@inheritdoc}
125
     */
126
    public function delete($key): bool
127
    {
128
        try {
129
            $key = self::sanitizeKey($key);
130
            Util\File::getFS()->remove($this->getFilePathname($key));
131
            $this->prune($key);
132
        } catch (\Exception $e) {
133
            $this->builder->getLogger()->error($e->getMessage());
134
135
            return false;
136
        }
137
138
        return true;
139
    }
140
141
    /**
142
     * {@inheritdoc}
143
     */
144
    public function clear(): bool
145
    {
146
        try {
147
            Util\File::getFS()->remove($this->cacheDir);
148
        } catch (\Exception $e) {
149
            $this->builder->getLogger()->error($e->getMessage());
150
151
            return false;
152
        }
153
154
        return true;
155
    }
156
157
    /**
158
     * {@inheritdoc}
159
     */
160
    public function getMultiple($keys, $default = null): iterable
161
    {
162
        throw new \Exception(\sprintf('%s::%s not yet implemented.', __CLASS__, __FUNCTION__));
163
    }
164
165
    /**
166
     * {@inheritdoc}
167
     */
168
    public function setMultiple($values, $ttl = null): bool
169
    {
170
        throw new \Exception(\sprintf('%s::%s not yet implemented.', __CLASS__, __FUNCTION__));
171
    }
172
173
    /**
174
     * {@inheritdoc}
175
     */
176
    public function deleteMultiple($keys): bool
177
    {
178
        throw new \Exception(\sprintf('%s::%s not yet implemented.', __CLASS__, __FUNCTION__));
179
    }
180
181
    /**
182
     * Creates key from a name and a hash: "$name__HASH__VERSION".
183
     */
184 1
    public function createKey(string $name, string $hash): string
185
    {
186 1
        $name = self::sanitizeKey($name);
187
188 1
        return \sprintf('%s__%s__%s', $name, $hash, $this->builder->getVersion());
189
    }
190
191
    /**
192
     * Creates key from a string: "$name__HASH__VERSION".
193
     * $name is optional to add a human readable name to the key.
194
     */
195 1
    public function createKeyFromValue(?string $name, string $value): string
196
    {
197 1
        $hash = hash('md5', $value);
198 1
        $name = $name ?? $hash;
199
200 1
        return $this->createKey($name, $hash);
201
    }
202
203
    /**
204
     * Creates key from an Asset: "$path_$ext_$tags__HASH__VERSION".
205
     */
206 1
    public function createKeyFromAsset(Asset $asset, ?array $tags = null): string
207
    {
208 1
        $t = $tags;
209 1
        $tags = [];
210
211 1
        if ($t !== null) {
212 1
            foreach ($t as $key => $value) {
213 1
                switch (\gettype($value)) {
214 1
                    case 'boolean':
215 1
                        if ($value === true) {
216 1
                            $tags[] = $key;
217
                        }
218 1
                        break;
219 1
                    case 'string':
220 1
                    case 'integer':
221 1
                        if (!empty($value)) {
222 1
                            $tags[] = substr($key, 0, 1) . $value;
223
                        }
224 1
                        break;
225
                }
226
            }
227
        }
228
229 1
        $tagsInline = implode('_', str_replace('_', '', $tags));
230 1
        $name = "{$asset['_path']}_{$asset['ext']}_$tagsInline";
231
232
        // backward compatibility
233 1
        if (empty($asset['hash']) or \is_null($asset['hash'])) {
234
            throw new RuntimeException(\sprintf('Asset "%s" has no hash. Please clear cache and retry.', $name));
235
        }
236
237 1
        return $this->createKey($name, $asset['hash']);
0 ignored issues
show
Bug introduced by
It seems like $asset['hash'] can also be of type null; however, parameter $hash of Cecil\Cache::createKey() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

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

237
        return $this->createKey($name, /** @scrutinizer ignore-type */ $asset['hash']);
Loading history...
238
    }
239
240
    /**
241
     * Creates key from a file: "RelativePathname__MD5".
242
     *
243
     * @throws RuntimeException
244
     */
245 1
    public function createKeyFromFile(\Symfony\Component\Finder\SplFileInfo $file): string
246
    {
247 1
        if (false === $content = Util\File::fileGetContents($file->getRealPath())) {
248
            throw new RuntimeException(\sprintf('Can\'t create cache key for "%s".', $file));
249
        }
250
251 1
        return $this->createKeyFromValue($file->getRelativePathname(), $content);
252
    }
253
254
    /**
255
     * Clear cache by pattern.
256
     */
257
    public function clearByPattern(string $pattern): int
258
    {
259
        try {
260
            if (!Util\File::getFS()->exists($this->cacheDir)) {
261
                throw new RuntimeException(\sprintf('The cache directory "%s" does not exists.', $this->cacheDir));
262
            }
263
            $fileCount = 0;
264
            $iterator = new \RecursiveIteratorIterator(
265
                new \RecursiveDirectoryIterator($this->cacheDir),
266
                \RecursiveIteratorIterator::SELF_FIRST
267
            );
268
            foreach ($iterator as $file) {
269
                if ($file->isFile()) {
270
                    if (preg_match('/' . $pattern . '/i', $file->getPathname())) {
271
                        Util\File::getFS()->remove($file->getPathname());
272
                        $fileCount++;
273
                        $this->builder->getLogger()->debug(\sprintf('Cache removed: "%s"', trim(Util\File::getFS()->makePathRelative($file->getPathname(), $this->builder->getConfig()->getCachePath()), '/')));
274
                    }
275
                }
276
            }
277
        } catch (\Exception $e) {
278
            $this->builder->getLogger()->error($e->getMessage());
279
280
            return 0;
281
        }
282
283
        return $fileCount;
284
    }
285
286
    /**
287
     * Returns cache content file pathname from path.
288
     */
289 1
    public function getContentFilePathname(string $path): string
290
    {
291 1
        $path = str_replace(['https://', 'http://'], '', $path); // remove protocol (if URL)
292
293 1
        return Util::joinFile($this->cacheDir, 'files', $path);
294
    }
295
296
    /**
297
     * Returns cache file pathname from key.
298
     */
299 1
    private function getFilePathname(string $key): string
300
    {
301 1
        return Util::joinFile($this->cacheDir, "$key.ser");
302
    }
303
304
    /**
305
     * Prepares and validate $key.
306
     */
307 1
    public static function sanitizeKey(string $key): string
308
    {
309 1
        $key = str_replace(['https://', 'http://'], '', $key); // remove protocol (if URL)
310 1
        $key = Page::slugify($key);                            // slugify
311 1
        $key = trim($key, '/');                                // remove leading/trailing slashes
312 1
        $key = str_replace(['\\', '/'], ['-', '-'], $key);     // replace slashes by hyphens
313 1
        $key = substr($key, 0, 200);                           // truncate to 200 characters (NTFS filename length limit is 255 characters)
314
315 1
        return $key;
316
    }
317
318
    /**
319
     * Removes previous cache files.
320
     */
321 1
    private function prune(string $key): bool
322
    {
323
        try {
324 1
            $keyAsArray = explode('__', self::sanitizeKey($key));
325
            // if 2 or more parts (with hash), remove all files with the same first part
326
            // pattern: `name__hash__version`
327 1
            if (!empty($keyAsArray[0]) && \count($keyAsArray) >= 2) {
328 1
                $pattern = Util::joinFile($this->cacheDir, $keyAsArray[0]) . '*';
329 1
                foreach (glob($pattern) ?: [] as $filename) {
330 1
                    Util\File::getFS()->remove($filename);
331
                }
332
            }
333
        } catch (\Exception $e) {
334
            $this->builder->getLogger()->error($e->getMessage());
335
336
            return false;
337
        }
338
339 1
        return true;
340
    }
341
342
    /**
343
     * Convert the various expressions of a TTL value into duration in seconds.
344
     */
345 1
    protected function duration(int|\DateInterval $ttl): int
346
    {
347 1
        if (\is_int($ttl)) {
348 1
            return $ttl;
349
        }
350
        if ($ttl instanceof \DateInterval) {
351
            return (int) $ttl->d * 86400 + $ttl->h * 3600 + $ttl->i * 60 + $ttl->s;
352
        }
353
354
        throw new \InvalidArgumentException('TTL values must be int or \DateInterval');
355
    }
356
357
    /**
358
     * Removes the cache content file.
359
     */
360
    protected function deleteContentFile(string $path): bool
361
    {
362
        try {
363
            Util\File::getFS()->remove($this->getContentFilePathname($path));
364
        } catch (\Exception $e) {
365
            $this->builder->getLogger()->error($e->getMessage());
366
367
            return false;
368
        }
369
370
        return true;
371
    }
372
}
373