Passed
Push — master ( dfd7ad...48777e )
by Arnaud
12:36 queued 06:46
created

Cache::createKeyFromValue()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 3
nc 1
nop 2
dl 0
loc 6
ccs 4
cts 4
cp 1
crap 1
rs 10
c 0
b 0
f 0
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 1
    public function __construct(Builder $builder, string $pool = '')
31
    {
32 1
        $this->builder = $builder;
33 1
        $this->cacheDir = Util::joinFile($builder->getConfig()->getCachePath(), $pool);
34
    }
35
36
    /**
37
     * {@inheritdoc}
38
     */
39 1
    public function set($key, $value, $ttl = null): bool
40
    {
41
        try {
42 1
            $key = self::sanitizeKey($key);
43 1
            $this->prune($key);
44
            // put file content in a dedicated file
45 1
            if (\is_array($value) && !empty($value['content']) && !empty($value['path'])) {
46 1
                Util\File::getFS()->dumpFile($this->getContentFilePathname($value['path']), $value['content']);
47 1
                unset($value['content']);
48
            }
49
            // serialize data
50 1
            $data = serialize([
51 1
                'value'      => $value,
52 1
                'expiration' => $ttl === null ? null : time() + $this->duration($ttl),
53 1
            ]);
54 1
            Util\File::getFS()->dumpFile($this->getFilePathname($key), $data);
55
        } catch (\Exception $e) {
56
            $this->builder->getLogger()->error($e->getMessage());
57
58
            return false;
59
        }
60
61 1
        return true;
62
    }
63
64
    /**
65
     * {@inheritdoc}
66
     */
67 1
    public function has($key): bool
68
    {
69 1
        $key = self::sanitizeKey($key);
70 1
        if (!Util\File::getFS()->exists($this->getFilePathname($key))) {
71 1
            return false;
72
        }
73
74 1
        return true;
75
    }
76
77
    /**
78
     * {@inheritdoc}
79
     */
80 1
    public function get($key, $default = null): mixed
81
    {
82
        try {
83 1
            $key = self::sanitizeKey($key);
84
            // return default value if file doesn't exists
85 1
            if (false === $content = Util\File::fileGetContents($this->getFilePathname($key))) {
86
                return $default;
87
            }
88
            // unserialize data
89 1
            $data = unserialize($content);
90
            // check expiration
91 1
            if ($data['expiration'] !== null && $data['expiration'] <= time()) {
92
                $this->builder->getLogger()->debug(\sprintf('Cache expired: "%s"', $key));
93
                // remove expired cache
94
                if ($this->delete($key)) {
95
                    // remove content file if exists
96
                    if (!empty($data['value']['path'])) {
97
                        $this->deleteContentFile($data['value']['path']);
98
                    }
99
                }
100
101
                return $default;
102
            }
103
            // get content from dedicated file
104 1
            if (\is_array($data['value']) && isset($data['value']['path'])) {
105 1
                if (false !== $content = Util\File::fileGetContents($this->getContentFilePathname($data['value']['path']))) {
106 1
                    $data['value']['content'] = $content;
107
                }
108
            }
109
        } catch (\Exception $e) {
110
            $this->builder->getLogger()->error($e->getMessage());
111
112
            return $default;
113
        }
114
115 1
        return $data['value'];
116
    }
117
118
    /**
119
     * {@inheritdoc}
120
     */
121
    public function delete($key): bool
122
    {
123
        try {
124
            $key = self::sanitizeKey($key);
125
            Util\File::getFS()->remove($this->getFilePathname($key));
126
            $this->prune($key);
127
        } catch (\Exception $e) {
128
            $this->builder->getLogger()->error($e->getMessage());
129
130
            return false;
131
        }
132
133
        return true;
134
    }
135
136
    /**
137
     * {@inheritdoc}
138
     */
139
    public function clear(): bool
140
    {
141
        try {
142
            Util\File::getFS()->remove($this->cacheDir);
143
        } catch (\Exception $e) {
144
            $this->builder->getLogger()->error($e->getMessage());
145
146
            return false;
147
        }
148
149
        return true;
150
    }
151
152
    /**
153
     * {@inheritdoc}
154
     */
155
    public function getMultiple($keys, $default = null): iterable
156
    {
157
        throw new \Exception(\sprintf('%s::%s not yet implemented.', __CLASS__, __FUNCTION__));
158
    }
159
160
    /**
161
     * {@inheritdoc}
162
     */
163
    public function setMultiple($values, $ttl = null): bool
164
    {
165
        throw new \Exception(\sprintf('%s::%s not yet implemented.', __CLASS__, __FUNCTION__));
166
    }
167
168
    /**
169
     * {@inheritdoc}
170
     */
171
    public function deleteMultiple($keys): bool
172
    {
173
        throw new \Exception(\sprintf('%s::%s not yet implemented.', __CLASS__, __FUNCTION__));
174
    }
175
176
    /**
177
     * Creates key from a name and a hash: "$name__HASH__VERSION".
178
     */
179 1
    public function createKey(string $name, string $hash): string
180
    {
181 1
        $name = self::sanitizeKey($name);
182
183 1
        return \sprintf('%s__%s__%s', $name, $hash, $this->builder->getVersion());
184
    }
185
186
    /**
187
     * Creates key from a string: "$name__HASH__VERSION".
188
     * $name is optional to add a human readable name to the key.
189
     */
190 1
    public function createKeyFromValue(?string $name, string $value): string
191
    {
192 1
        $hash = hash('md5', $value);
193 1
        $name = $name ?? $hash;
194
195 1
        return $this->createKey($name, $hash);
196
    }
197
198
    /**
199
     * Creates key from an Asset: "$path_$ext_$tags__HASH__VERSION".
200
     */
201 1
    public function createKeyFromAsset(Asset $asset, ?array $tags = null): string
202
    {
203 1
        $t = $tags;
204 1
        $tags = [];
205
206 1
        if ($t !== null) {
207 1
            foreach ($t as $key => $value) {
208 1
                switch (\gettype($value)) {
209 1
                    case 'boolean':
210 1
                        if ($value === true) {
211 1
                            $tags[] = $key;
212
                        }
213 1
                        break;
214 1
                    case 'string':
215 1
                    case 'integer':
216 1
                        if (!empty($value)) {
217 1
                            $tags[] = substr($key, 0, 1) . $value;
218
                        }
219 1
                        break;
220
                }
221
            }
222
        }
223
224 1
        $tagsInline = implode('_', str_replace('_', '', $tags));
225 1
        $name = "{$asset['_path']}_{$asset['ext']}_$tagsInline";
226
227 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\Assets\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

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