Passed
Push — cache ( 90404a...dd49de )
by Arnaud
03:49
created

Cache::getContentFilePathname()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

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
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
    public function __construct(Builder $builder, string $pool = '')
34
    {
35
        $this->builder = $builder;
36
        $this->cacheDir = Util::joinFile($builder->getConfig()->getCachePath(), $pool);
37
        $this->duration = 31536000; // 1 year
38
    }
39
40
    /**
41
     * {@inheritdoc}
42
     */
43
    public function get($key, $default = null): mixed
44
    {
45
        try {
46
            $key = $this->prepareKey($key);
47
            // return default value if file doesn't exists
48
            if (false === $content = Util\File::fileGetContents($this->getFilePathname($key))) {
49
                return $default;
50
            }
51
            // unserialize data
52
            $data = unserialize($content);
53
            // check expiration
54
            if ($data['expiration'] <= time()) {
55
                $this->delete($key);
56
57
                return $default;
58
            }
59
            // get content from dedicated file
60
            if (\is_array($data['value']) && isset($data['value']['path'])) {
61
                if (false !== $content = Util\File::fileGetContents($this->getContentFilePathname($data['value']['path']))) {
62
                    $data['value']['content'] = $content;
63
                }
64
            }
65
        } catch (\Exception $e) {
66
            $this->builder->getLogger()->error($e->getMessage());
67
68
            return $default;
69
        }
70
71
        return $data['value'];
72
    }
73
74
    /**
75
     * {@inheritdoc}
76
     */
77
    public function set($key, $value, $ttl = null): bool
78
    {
79
        try {
80
            $key = $this->prepareKey($key);
81
            $this->prune($key);
82
            // put file content in a dedicated file
83
            if (\is_array($value) && isset($value['content']) && isset($value['path'])) {
84
                Util\File::getFS()->dumpFile($this->getContentFilePathname($value['path']), $value['content']);
85
                unset($value['content']);
86
            }
87
            // serialize data
88
            $data = serialize([
89
                'value'      => $value,
90
                'expiration' => time() + $this->duration($ttl),
91
            ]);
92
            Util\File::getFS()->dumpFile($this->getFilePathname($key), $data);
93
        } catch (\Exception $e) {
94
            $this->builder->getLogger()->error($e->getMessage());
95
96
            return false;
97
        }
98
99
        return true;
100
    }
101
102
    /**
103
     * {@inheritdoc}
104
     */
105
    public function delete($key): bool
106
    {
107
        try {
108
            $key = $this->prepareKey($key);
109
            Util\File::getFS()->remove($this->getFilePathname($key));
110
            $this->prune($key);
111
        } catch (\Exception $e) {
112
            $this->builder->getLogger()->error($e->getMessage());
113
114
            return false;
115
        }
116
117
        return true;
118
    }
119
120
    /**
121
     * {@inheritdoc}
122
     */
123
    public function clear(): bool
124
    {
125
        try {
126
            Util\File::getFS()->remove($this->cacheDir);
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 getMultiple($keys, $default = null): iterable
140
    {
141
        throw new \Exception(\sprintf('%s::%s not yet implemented.', __CLASS__, __FUNCTION__));
142
    }
143
144
    /**
145
     * {@inheritdoc}
146
     */
147
    public function setMultiple($values, $ttl = null): bool
148
    {
149
        throw new \Exception(\sprintf('%s::%s not yet implemented.', __CLASS__, __FUNCTION__));
150
    }
151
152
    /**
153
     * {@inheritdoc}
154
     */
155
    public function deleteMultiple($keys): bool
156
    {
157
        throw new \Exception(\sprintf('%s::%s not yet implemented.', __CLASS__, __FUNCTION__));
158
    }
159
160
    /**
161
     * {@inheritdoc}
162
     */
163
    public function has($key): bool
164
    {
165
        $key = $this->prepareKey($key);
166
        if (!Util\File::getFS()->exists($this->getFilePathname($key))) {
167
            return false;
168
        }
169
170
        return true;
171
    }
172
173
    /**
174
     * Creates key with the MD5 hash of a string.
175
     */
176
    public function createKeyFromString(string $value, ?string $suffix = null): string
177
    {
178
        return \sprintf('%s%s__%s', hash('md5', $value), ($suffix ? '_' . $suffix : ''), $this->builder->getVersion());
179
    }
180
181
    /**
182
     * Creates key from a file: "$relativePath__MD5".
183
     *
184
     * @throws RuntimeException
185
     */
186
    public function createKeyFromPath(string $path, string $relativePath): string
187
    {
188
        if (false === $content = Util\File::fileGetContents($path)) {
189
            throw new RuntimeException(\sprintf('Can\'t create cache key for "%s".', $path));
190
        }
191
192
        return $this->prepareKey(\sprintf('%s__%s', $relativePath, $this->createKeyFromString($content)));
193
    }
194
195
    /**
196
     * Creates key from an Asset: "$filename_$ext_$tags__VERSION__MD5".
197
     */
198
    public function createKeyFromAsset(Asset $asset, ?array $tags = null): string
199
    {
200
        $tags = implode('_', $tags ?? []);
201
202
        return $this->prepareKey(\sprintf(
203
            '%s%s%s__%s',
204
            $asset['filename'],
205
            "_{$asset['ext']}",
206
            $tags ? "_$tags" : '',
207
            $this->createKeyFromString($asset['content'] ?? '')
208
        ));
209
    }
210
211
    /**
212
     * Clear cache by pattern.
213
     */
214
    public function clearByPattern(string $pattern): int
215
    {
216
        try {
217
            if (!Util\File::getFS()->exists($this->cacheDir)) {
218
                throw new RuntimeException(\sprintf('The cache directory "%s" does not exists.', $this->cacheDir));
219
            }
220
            $fileCount = 0;
221
            $iterator = new \RecursiveIteratorIterator(
222
                new \RecursiveDirectoryIterator($this->cacheDir),
223
                \RecursiveIteratorIterator::SELF_FIRST
224
            );
225
            foreach ($iterator as $file) {
226
                if ($file->isFile()) {
227
                    if (preg_match('/' . $pattern . '/i', $file->getPathname())) {
228
                        Util\File::getFS()->remove($file->getPathname());
229
                        $fileCount++;
230
                        $this->builder->getLogger()->debug(\sprintf('Cache file "%s" removed', Util\File::getFS()->makePathRelative($file->getPathname(), $this->builder->getConfig()->getCachePath())));
231
                    }
232
                }
233
            }
234
        } catch (\Exception $e) {
235
            $this->builder->getLogger()->error($e->getMessage());
236
237
            return 0;
238
        }
239
240
        return $fileCount;
241
    }
242
243
    /**
244
     * Returns cache content file pathname from path.
245
     */
246
    public function getContentFilePathname(string $path): string
247
    {
248
        return Util::joinFile($this->builder->getConfig()->getCacheAssetsFilesPath(), $path);
249
    }
250
251
    /**
252
     * Returns cache file pathname from key.
253
     */
254
    private function getFilePathname(string $key): string
255
    {
256
        return Util::joinFile($this->cacheDir, \sprintf('%s.ser', $key));
257
    }
258
259
    /**
260
     * Removes previous cache files.
261
     */
262
    private function prune(string $key): bool
263
    {
264
        try {
265
            $keyAsArray = explode('__', $this->prepareKey($key));
266
            if (!empty($keyAsArray[0])) {
267
                $pattern = Util::joinFile($this->cacheDir, $keyAsArray[0]) . '*';
268
                foreach (glob($pattern) ?: [] as $filename) {
269
                    Util\File::getFS()->remove($filename);
270
                    $this->builder->getLogger()->debug(\sprintf('Cache file "%s" removed', Util\File::getFS()->makePathRelative($filename, $this->builder->getConfig()->getCachePath())));
271
                }
272
            }
273
        } catch (\Exception $e) {
274
            $this->builder->getLogger()->error($e->getMessage());
275
276
            return false;
277
        }
278
279
        return true;
280
    }
281
282
    /**
283
     * $key must be a valid string.
284
     */
285
    private function prepareKey(string $key): string
286
    {
287
        $key = str_replace(['https://', 'http://'], '', $key);
288
        $key = Page::slugify($key);
289
        $key = trim($key, '/');
290
        $key = str_replace(['\\', '/'], ['-', '-'], $key);
291
        $key = substr($key, 0, 200); // Maximum filename length in NTFS?
292
293
        return $key;
294
    }
295
296
    /**
297
     * Convert the various expressions of a TTL value into duration in seconds.
298
     */
299
    protected function duration(\DateInterval|int|null $ttl): int
300
    {
301
        if ($ttl === null) {
302
            return $this->duration;
303
        }
304
        if (\is_int($ttl)) {
305
            return $ttl;
306
        }
307
        if ($ttl instanceof \DateInterval) {
308
            return (int)$ttl->format('%s');
309
        }
310
311
        throw new \InvalidArgumentException('TTL values must be one of null, int, \DateInterval');
312
    }
313
}
314