Passed
Pull Request — master (#2148)
by Arnaud
09:31 queued 04:12
created

Cache::createKeyFromFile()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 7
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 2.0625

Importance

Changes 0
Metric Value
cc 2
eloc 3
nc 2
nop 1
dl 0
loc 7
ccs 3
cts 4
cp 0.75
crap 2.0625
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
    /** @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 1
            $this->builder->getLogger()->debug(\sprintf('Cache created: "%s"', Util\File::getFS()->makePathRelative($this->getFilePathname($key), $this->builder->getConfig()->getCachePath())));
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'] <= time()) {
97
                $this->delete($key);
98
99
                return $default;
100
            }
101
            // get content from dedicated file
102 1
            if (\is_array($data['value']) && isset($data['value']['path'])) {
103 1
                if (false !== $content = Util\File::fileGetContents($this->getContentFilePathname($data['value']['path']))) {
104 1
                    $data['value']['content'] = $content;
105
                }
106
            }
107
        } catch (\Exception $e) {
108
            $this->builder->getLogger()->error($e->getMessage());
109
110
            return $default;
111
        }
112
113 1
        return $data['value'];
114
    }
115
116
    /**
117
     * {@inheritdoc}
118
     */
119
    public function delete($key): bool
120
    {
121
        try {
122
            $key = self::sanitizeKey($key);
123
            Util\File::getFS()->remove($this->getFilePathname($key));
124
            $this->prune($key);
125
        } catch (\Exception $e) {
126
            $this->builder->getLogger()->error($e->getMessage());
127
128
            return false;
129
        }
130
131
        return true;
132
    }
133
134
    /**
135
     * {@inheritdoc}
136
     */
137
    public function clear(): bool
138
    {
139
        try {
140
            Util\File::getFS()->remove($this->cacheDir);
141
        } catch (\Exception $e) {
142
            $this->builder->getLogger()->error($e->getMessage());
143
144
            return false;
145
        }
146
147
        return true;
148
    }
149
150
    /**
151
     * {@inheritdoc}
152
     */
153
    public function getMultiple($keys, $default = null): iterable
154
    {
155
        throw new \Exception(\sprintf('%s::%s not yet implemented.', __CLASS__, __FUNCTION__));
156
    }
157
158
    /**
159
     * {@inheritdoc}
160
     */
161
    public function setMultiple($values, $ttl = null): bool
162
    {
163
        throw new \Exception(\sprintf('%s::%s not yet implemented.', __CLASS__, __FUNCTION__));
164
    }
165
166
    /**
167
     * {@inheritdoc}
168
     */
169
    public function deleteMultiple($keys): bool
170
    {
171
        throw new \Exception(\sprintf('%s::%s not yet implemented.', __CLASS__, __FUNCTION__));
172
    }
173
174
    /**
175
     * Creates key from a string: "$name|uniqid__HASH__VERSION".
176
     * $name is optional to add a human readable name to the key.
177
     */
178 1
    public function createKey(?string $name, string $value): string
179
    {
180 1
        $hash = hash('md5', $value);
181 1
        $name = $name ? self::sanitizeKey($name) : $hash;
182
183 1
        return \sprintf('%s__%s__%s', $name, $hash, $this->builder->getVersion());
184
    }
185
186
    /**
187
     * Creates key from an Asset: "$path_$ext_$tags__HASH__VERSION".
188
     */
189 1
    public function createKeyFromAsset(Asset $asset, ?array $tags = null): string
190
    {
191 1
        $t = $tags;
192 1
        $tags = [];
193
194 1
        if ($t !== null) {
195 1
            ksort($t);
196 1
            foreach ($t as $key => $value) {
197 1
                switch (\gettype($value)) {
198 1
                    case 'boolean':
199 1
                        if ($value === true) {
200 1
                            $tags[] = $key;
201
                        }
202 1
                        break;
203 1
                    case 'string':
204 1
                    case 'integer':
205 1
                        if (!empty($value)) {
206 1
                            $tags[] = substr($key, 0, 1) . $value;
207
                        }
208 1
                        break;
209
                }
210
            }
211
        }
212
213 1
        $tagsInline = implode('_', $tags);
214 1
        $name = "{$asset['_path']}_{$asset['ext']}_$tagsInline";
215
216 1
        return $this->createKey($name, $asset['content'] ?? '');
217
    }
218
219
    /**
220
     * Creates key from a file: "RelativePathname__MD5".
221
     *
222
     * @throws RuntimeException
223
     */
224 1
    public function createKeyFromFile(\Symfony\Component\Finder\SplFileInfo $file): string
225
    {
226 1
        if (false === $content = Util\File::fileGetContents($file->getRealPath())) {
227
            throw new RuntimeException(\sprintf('Can\'t create cache key for "%s".', $file));
228
        }
229
230 1
        return $this->createKey($file->getRelativePathname(), $content);
231
    }
232
233
    /**
234
     * Clear cache by pattern.
235
     */
236
    public function clearByPattern(string $pattern): int
237
    {
238
        try {
239
            if (!Util\File::getFS()->exists($this->cacheDir)) {
240
                throw new RuntimeException(\sprintf('The cache directory "%s" does not exists.', $this->cacheDir));
241
            }
242
            $fileCount = 0;
243
            $iterator = new \RecursiveIteratorIterator(
244
                new \RecursiveDirectoryIterator($this->cacheDir),
245
                \RecursiveIteratorIterator::SELF_FIRST
246
            );
247
            foreach ($iterator as $file) {
248
                if ($file->isFile()) {
249
                    if (preg_match('/' . $pattern . '/i', $file->getPathname())) {
250
                        Util\File::getFS()->remove($file->getPathname());
251
                        $fileCount++;
252
                        $this->builder->getLogger()->debug(\sprintf('Cache removed: "%s"', Util\File::getFS()->makePathRelative($file->getPathname(), $this->builder->getConfig()->getCachePath())));
253
                    }
254
                }
255
            }
256
        } catch (\Exception $e) {
257
            $this->builder->getLogger()->error($e->getMessage());
258
259
            return 0;
260
        }
261
262
        return $fileCount;
263
    }
264
265
    /**
266
     * Returns cache content file pathname from path.
267
     */
268 1
    public function getContentFilePathname(string $path): string
269
    {
270 1
        $path = str_replace(['https://', 'http://'], '', $path); // remove protocol (if URL)
271
272 1
        return Util::joinFile($this->cacheDir, 'files', $path);
273
    }
274
275
    /**
276
     * Returns cache file pathname from key.
277
     */
278 1
    private function getFilePathname(string $key): string
279
    {
280 1
        return Util::joinFile($this->cacheDir, "$key.ser");
281
    }
282
283
    /**
284
     * Prepares and validate $key.
285
     */
286 1
    public static function sanitizeKey(string $key): string
287
    {
288 1
        $key = str_replace(['https://', 'http://'], '', $key); // remove protocol (if URL)
289 1
        $key = Page::slugify($key);                            // slugify
290 1
        $key = trim($key, '/');                                // remove leading/trailing slashes
291 1
        $key = str_replace(['\\', '/'], ['-', '-'], $key);     // replace slashes by hyphens
292 1
        $key = substr($key, 0, 200);                           // truncate to 200 characters (NTFS filename length limit is 255 characters)
293
294 1
        return $key;
295
    }
296
297
    /**
298
     * Removes previous cache files.
299
     */
300 1
    private function prune(string $key): bool
301
    {
302
        try {
303 1
            $keyAsArray = explode('__', self::sanitizeKey($key));
304
            // if 2 or more parts (with hash), remove all files with the same first part
305
            // pattern: `name__hash__version`
306 1
            if (!empty($keyAsArray[0]) && \count($keyAsArray) >= 2) {
307 1
                $pattern = Util::joinFile($this->cacheDir, $keyAsArray[0]) . '*';
308 1
                foreach (glob($pattern) ?: [] as $filename) {
309 1
                    Util\File::getFS()->remove($filename);
310 1
                    $this->builder->getLogger()->debug(\sprintf('Cache removed: "%s"', Util\File::getFS()->makePathRelative($filename, $this->builder->getConfig()->getCachePath())));
311
                }
312
            }
313
        } catch (\Exception $e) {
314
            $this->builder->getLogger()->error($e->getMessage());
315
316
            return false;
317
        }
318
319 1
        return true;
320
    }
321
322
    /**
323
     * Convert the various expressions of a TTL value into duration in seconds.
324
     */
325 1
    protected function duration(\DateInterval|int|null $ttl): int
326
    {
327 1
        if ($ttl === null) {
328 1
            return $this->duration;
329
        }
330 1
        if (\is_int($ttl)) {
331
            return $ttl;
332
        }
333 1
        if ($ttl instanceof \DateInterval) {
334 1
            return (int) $ttl->d * 86400 + $ttl->h * 3600 + $ttl->i * 60 + $ttl->s;
335
        }
336
337
        throw new \InvalidArgumentException('TTL values must be one of null, int, \DateInterval');
338
    }
339
}
340