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

Cache::createKeyFromAsset()   B

Complexity

Conditions 7
Paths 8

Size

Total Lines 23
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 17
CRAP Score 7

Importance

Changes 3
Bugs 0 Features 0
Metric Value
cc 7
eloc 16
c 3
b 0
f 0
nc 8
nop 2
dl 0
loc 23
ccs 17
cts 17
cp 1
crap 7
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 set($key, $value, $ttl = null): bool
44
    {
45
        try {
46 1
            $key = self::sanitizeKey($key);
47 1
            $this->prune($key);
48
            // put file content in a dedicated file
49 1
            if (\is_array($value) && !empty($value['content']) && !empty($value['path'])) {
50 1
                Util\File::getFS()->dumpFile($this->getContentFilePathname($value['path']), $value['content']);
51 1
                unset($value['content']);
52
            }
53
            // serialize data
54 1
            $data = serialize([
55 1
                'value'      => $value,
56 1
                'expiration' => time() + $this->duration($ttl),
57 1
            ]);
58 1
            Util\File::getFS()->dumpFile($this->getFilePathname($key), $data);
59
        } catch (\Exception $e) {
60
            $this->builder->getLogger()->error($e->getMessage());
61
62
            return false;
63
        }
64
65 1
        return true;
66
    }
67
68
    /**
69
     * {@inheritdoc}
70
     */
71 1
    public function has($key): bool
72
    {
73 1
        $key = self::sanitizeKey($key);
74 1
        if (!Util\File::getFS()->exists($this->getFilePathname($key))) {
75 1
            return false;
76
        }
77
78 1
        return true;
79
    }
80
81
    /**
82
     * {@inheritdoc}
83
     */
84 1
    public function get($key, $default = null): mixed
85
    {
86
        try {
87 1
            $key = self::sanitizeKey($key);
88
            // return default value if file doesn't exists
89 1
            if (false === $content = Util\File::fileGetContents($this->getFilePathname($key))) {
90
                return $default;
91
            }
92
            // unserialize data
93 1
            $data = unserialize($content);
94
            // check expiration
95 1
            if ($data['expiration'] <= time()) {
96
                $this->delete($key);
97
98
                return $default;
99
            }
100
            // get content from dedicated file
101 1
            if (\is_array($data['value']) && isset($data['value']['path'])) {
102 1
                if (false !== $content = Util\File::fileGetContents($this->getContentFilePathname($data['value']['path']))) {
103 1
                    $data['value']['content'] = $content;
104
                }
105
            }
106
        } catch (\Exception $e) {
107
            $this->builder->getLogger()->error($e->getMessage());
108
109
            return $default;
110
        }
111
112 1
        return $data['value'];
113
    }
114
115
    /**
116
     * {@inheritdoc}
117
     */
118
    public function delete($key): bool
119
    {
120
        try {
121
            $key = self::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 from a string: "$name|uniqid__HASH__VERSION".
175
     * $name is optional to add a human readable name to the key.
176
     */
177 1
    public function createKey(?string $name, string $value): string
178
    {
179 1
        $hash = hash('md5', $value);
180 1
        $name = $name ? self::sanitizeKey($name) : $hash;
181
182 1
        return \sprintf('%s__%s__%s', $name, $hash, $this->builder->getVersion());
183
    }
184
185
    /**
186
     * Creates key from an Asset: "$path_$ext_$tags__HASH__VERSION".
187
     */
188 1
    public function createKeyFromAsset(Asset $asset, ?array $tags = null): string
189
    {
190 1
        ksort($tags);
0 ignored issues
show
Bug introduced by
It seems like $tags can also be of type null; however, parameter $array of ksort() does only seem to accept array, 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

190
        ksort(/** @scrutinizer ignore-type */ $tags);
Loading history...
191 1
        $t = [];
192 1
        foreach ($tags as $key => $value) {
193 1
            switch (\gettype($value)) {
194 1
                case 'boolean':
195 1
                    if ($value === true) {
196 1
                        $t[] = $key;
197
                    }
198 1
                    break;
199 1
                case 'string':
200 1
                case 'integer':
201 1
                    if (!empty($value)) {
202 1
                        $t[] = substr($key, 0, 1) . $value;
203
                    }
204 1
                    break;
205
            }
206
        }
207 1
        $tagsInline = implode('_', $t);
208 1
        $name = "{$asset['_path']}_{$asset['ext']}_$tagsInline";
209
210 1
        return $this->createKey($name, $asset['content'] ?? []);
0 ignored issues
show
Bug introduced by
It seems like $asset['content'] ?? array() can also be of type array; however, parameter $value 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

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