Passed
Pull Request — master (#2148)
by Arnaud
11:38 queued 05:02
created

Cache::getFilePathname()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 1
c 1
b 0
f 0
nc 1
nop 1
dl 0
loc 3
ccs 2
cts 2
cp 1
crap 1
rs 10
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 has($key): bool
44
    {
45 1
        $key = self::sanitizeKey($key);
46 1
        if (!Util\File::getFS()->exists($this->getFilePathname($key))) {
47 1
            return false;
48
        }
49
50 1
        return true;
51
    }
52
53
    /**
54
     * {@inheritdoc}
55
     */
56 1
    public function get($key, $default = null): mixed
57
    {
58
        try {
59 1
            $key = self::sanitizeKey($key);
60
            // return default value if file doesn't exists
61 1
            if (false === $content = Util\File::fileGetContents($this->getFilePathname($key))) {
62
                return $default;
63
            }
64
            // unserialize data
65 1
            $data = unserialize($content);
66
            // check expiration
67 1
            if ($data['expiration'] <= time()) {
68
                $this->delete($key);
69
70
                return $default;
71
            }
72
            // get content from dedicated file
73 1
            if (\is_array($data['value']) && isset($data['value']['path'])) {
74 1
                if (false !== $content = Util\File::fileGetContents($this->getContentFilePathname($data['value']['path']))) {
75 1
                    $data['value']['content'] = $content;
76
                }
77
            }
78
        } catch (\Exception $e) {
79
            $this->builder->getLogger()->error($e->getMessage());
80
81
            return $default;
82
        }
83
84 1
        return $data['value'];
85
    }
86
87
    /**
88
     * {@inheritdoc}
89
     */
90 1
    public function set($key, $value, $ttl = null): bool
91
    {
92
        try {
93 1
            $key = self::sanitizeKey($key);
94 1
            $this->prune($key);
95
            // put file content in a dedicated file
96 1
            if (\is_array($value) && !empty($value['content']) && !empty($value['path'])) {
97 1
                Util\File::getFS()->dumpFile($this->getContentFilePathname($value['path']), $value['content']);
98 1
                unset($value['content']);
99
            }
100
            // serialize data
101 1
            $data = serialize([
102 1
                'value'      => $value,
103 1
                'expiration' => time() + $this->duration($ttl),
104 1
            ]);
105 1
            Util\File::getFS()->dumpFile($this->getFilePathname($key), $data);
106
        } catch (\Exception $e) {
107
            $this->builder->getLogger()->error($e->getMessage());
108
109
            return false;
110
        }
111
112 1
        return true;
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 with the MD5 hash of a string.
175
     * $suffix is optional to add a human readable suffix to the key (e.g. "css", "js").
176
     */
177 1
    public function createKeyFromString(string $value, ?string $suffix = null): string
178
    {
179 1
        return \sprintf('%s%s__%s', hash('md5', $value), ($suffix ? '_' . $suffix : ''), $this->builder->getVersion());
180
    }
181
182
    /**
183
     * Creates key from an Asset: "$path_$ext_$tags__HASH__VERSION".
184
     */
185 1
    public function createKeyFromAsset(Asset $asset, ?array $tags = null): string
186
    {
187 1
        $tags = implode('_', $tags ?? []);
188
189 1
        return self::sanitizeKey(\sprintf(
190 1
            '%s%s%s__%s',
191 1
            $asset['_path'],
192 1
            "_{$asset['ext']}",
193 1
            $tags ? "_$tags" : '',
194 1
            $this->createKeyFromString($asset['content'] ?? '')
195 1
        ));
196
    }
197
198
    /**
199
     * Creates key from a file: "RelativePathname__MD5".
200
     *
201
     * @throws RuntimeException
202
     */
203 1
    public function createKeyFromFile(\Symfony\Component\Finder\SplFileInfo $file): string
204
    {
205 1
        if (false === $content = Util\File::fileGetContents($file->getRealPath())) {
206
            throw new RuntimeException(\sprintf('Can\'t create cache key for "%s".', $file));
207
        }
208
209 1
        return self::sanitizeKey(\sprintf('%s__%s', $file->getRelativePathname(), $this->createKeyFromString($content)));
210
    }
211
212
    /**
213
     * Clear cache by pattern.
214
     */
215
    public function clearByPattern(string $pattern): int
216
    {
217
        try {
218
            if (!Util\File::getFS()->exists($this->cacheDir)) {
219
                throw new RuntimeException(\sprintf('The cache directory "%s" does not exists.', $this->cacheDir));
220
            }
221
            $fileCount = 0;
222
            $iterator = new \RecursiveIteratorIterator(
223
                new \RecursiveDirectoryIterator($this->cacheDir),
224
                \RecursiveIteratorIterator::SELF_FIRST
225
            );
226
            foreach ($iterator as $file) {
227
                if ($file->isFile()) {
228
                    if (preg_match('/' . $pattern . '/i', $file->getPathname())) {
229
                        Util\File::getFS()->remove($file->getPathname());
230
                        $fileCount++;
231
                        $this->builder->getLogger()->debug(\sprintf('Cache removed: "%s"', Util\File::getFS()->makePathRelative($file->getPathname(), $this->builder->getConfig()->getCachePath())));
232
                    }
233
                }
234
            }
235
        } catch (\Exception $e) {
236
            $this->builder->getLogger()->error($e->getMessage());
237
238
            return 0;
239
        }
240
241
        return $fileCount;
242
    }
243
244
    /**
245
     * Returns cache content file pathname from path.
246
     */
247 1
    public function getContentFilePathname(string $path): string
248
    {
249 1
        $path = str_replace(['https://', 'http://'], '', $path); // remove protocol (if URL)
250
251 1
        return Util::joinFile($this->cacheDir, 'files', $path);
252
    }
253
254
    /**
255
     * Returns cache file pathname from key.
256
     */
257 1
    private function getFilePathname(string $key): string
258
    {
259 1
        return Util::joinFile($this->cacheDir, "$key.ser");
260
    }
261
262
    /**
263
     * Prepares and validate $key.
264
     */
265 1
    public static function sanitizeKey(string $key): string
266
    {
267 1
        $key = str_replace(['https://', 'http://'], '', $key); // remove protocol (if URL)
268 1
        $key = Page::slugify($key);                            // slugify
269 1
        $key = trim($key, '/');                                // remove leading/trailing slashes
270 1
        $key = str_replace(['\\', '/'], ['-', '-'], $key);     // replace slashes by hyphens
271 1
        $key = substr($key, 0, 200);                           // truncate to 200 characters (NTFS filename length limit is 255 characters)
272
273 1
        return $key;
274
    }
275
276
    /**
277
     * Removes previous cache files.
278
     */
279 1
    private function prune(string $key): bool
280
    {
281
        try {
282 1
            $keyAsArray = explode('__', self::sanitizeKey($key));
283
            // if 3 parts (with hash), remove all files with the same first part
284
            // pattern: `path_tag__hash__version`
285 1
            if (!empty($keyAsArray[0]) && \count($keyAsArray) == 3) {
286 1
                $pattern = Util::joinFile($this->cacheDir, $keyAsArray[0]) . '*';
287 1
                foreach (glob($pattern) ?: [] as $filename) {
288 1
                    Util\File::getFS()->remove($filename);
289 1
                    $this->builder->getLogger()->debug(\sprintf('Cache removed: "%s"', Util\File::getFS()->makePathRelative($filename, $this->builder->getConfig()->getCachePath())));
290
                }
291
            }
292
        } catch (\Exception $e) {
293
            $this->builder->getLogger()->error($e->getMessage());
294
295
            return false;
296
        }
297
298 1
        return true;
299
    }
300
301
    /**
302
     * Convert the various expressions of a TTL value into duration in seconds.
303
     */
304 1
    protected function duration(\DateInterval|int|null $ttl): int
305
    {
306 1
        if ($ttl === null) {
307 1
            return $this->duration;
308
        }
309 1
        if (\is_int($ttl)) {
310
            return $ttl;
311
        }
312 1
        if ($ttl instanceof \DateInterval) {
313 1
            return (int) $ttl->d * 86400 + $ttl->h * 3600 + $ttl->i * 60 + $ttl->s;
314
        }
315
316
        throw new \InvalidArgumentException('TTL values must be one of null, int, \DateInterval');
317
    }
318
}
319