Issues (16)

Branch: master

src/Assets/Cache.php (1 issue)

Labels
Severity
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\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
/**
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'])) {
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
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

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"', 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