• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Cecilapp / Cecil / 14713733570

28 Apr 2025 05:15PM UTC coverage: 83.457% (-0.3%) from 83.736%
14713733570

push

github

ArnaudLigny
chore: update deps

3042 of 3645 relevant lines covered (83.46%)

0.83 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

52.85
/src/Assets/Cache.php
1
<?php
2

3
declare(strict_types=1);
4

5
/*
6
 * This file is part of Cecil.
7
 *
8
 * Copyright (c) Arnaud Ligny <arnaud@ligny.fr>
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
    public function __construct(Builder $builder, string $pool = '')
31
    {
32
        $this->builder = $builder;
1✔
33
        $this->cacheDir = Util::joinFile($builder->getConfig()->getCachePath(), $pool);
1✔
34
    }
35

36
    /**
37
     * {@inheritdoc}
38
     */
39
    public function set($key, $value, $ttl = null): bool
40
    {
41
        try {
42
            $key = self::sanitizeKey($key);
1✔
43
            $this->prune($key);
1✔
44
            // put file content in a dedicated file
45
            if (\is_array($value) && !empty($value['content']) && !empty($value['path'])) {
1✔
46
                Util\File::getFS()->dumpFile($this->getContentFilePathname($value['path']), $value['content']);
1✔
47
                unset($value['content']);
1✔
48
            }
49
            // serialize data
50
            $data = serialize([
1✔
51
                'value'      => $value,
1✔
52
                'expiration' => $ttl === null ? null : time() + $this->duration($ttl),
1✔
53
            ]);
1✔
54
            Util\File::getFS()->dumpFile($this->getFilePathname($key), $data);
1✔
55
            $this->builder->getLogger()->debug(\sprintf('Cache created: "%s"', Util\File::getFS()->makePathRelative($this->getFilePathname($key), $this->builder->getConfig()->getCachePath())));
1✔
56
        } catch (\Exception $e) {
×
57
            $this->builder->getLogger()->error($e->getMessage());
×
58

59
            return false;
×
60
        }
61

62
        return true;
1✔
63
    }
64

65
    /**
66
     * {@inheritdoc}
67
     */
68
    public function has($key): bool
69
    {
70
        $key = self::sanitizeKey($key);
1✔
71
        if (!Util\File::getFS()->exists($this->getFilePathname($key))) {
1✔
72
            return false;
1✔
73
        }
74

75
        return true;
1✔
76
    }
77

78
    /**
79
     * {@inheritdoc}
80
     */
81
    public function get($key, $default = null): mixed
82
    {
83
        try {
84
            $key = self::sanitizeKey($key);
1✔
85
            // return default value if file doesn't exists
86
            if (false === $content = Util\File::fileGetContents($this->getFilePathname($key))) {
1✔
87
                return $default;
×
88
            }
89
            // unserialize data
90
            $data = unserialize($content);
1✔
91
            // check expiration
92
            if ($data['expiration'] !== null && $data['expiration'] <= time()) {
1✔
93
                $this->builder->getLogger()->debug(\sprintf('Cache expired: "%s"', $key));
×
94
                // remove expired cache
95
                if ($this->delete($key)) {
×
96
                    // remove content file if exists
97
                    if (!empty($data['value']['path'])) {
×
98
                        $this->deleteContentFile($data['value']['path']);
×
99
                    }
100
                }
101

102
                return $default;
×
103
            }
104
            // get content from dedicated file
105
            if (\is_array($data['value']) && isset($data['value']['path'])) {
1✔
106
                if (false !== $content = Util\File::fileGetContents($this->getContentFilePathname($data['value']['path']))) {
1✔
107
                    $data['value']['content'] = $content;
1✔
108
                }
109
            }
110
        } catch (\Exception $e) {
×
111
            $this->builder->getLogger()->error($e->getMessage());
×
112

113
            return $default;
×
114
        }
115

116
        return $data['value'];
1✔
117
    }
118

119
    /**
120
     * {@inheritdoc}
121
     */
122
    public function delete($key): bool
123
    {
124
        try {
125
            $key = self::sanitizeKey($key);
×
126
            Util\File::getFS()->remove($this->getFilePathname($key));
×
127
            $this->prune($key);
×
128
        } catch (\Exception $e) {
×
129
            $this->builder->getLogger()->error($e->getMessage());
×
130

131
            return false;
×
132
        }
133

134
        return true;
×
135
    }
136

137
    /**
138
     * {@inheritdoc}
139
     */
140
    public function clear(): bool
141
    {
142
        try {
143
            Util\File::getFS()->remove($this->cacheDir);
×
144
        } catch (\Exception $e) {
×
145
            $this->builder->getLogger()->error($e->getMessage());
×
146

147
            return false;
×
148
        }
149

150
        return true;
×
151
    }
152

153
    /**
154
     * {@inheritdoc}
155
     */
156
    public function getMultiple($keys, $default = null): iterable
157
    {
158
        throw new \Exception(\sprintf('%s::%s not yet implemented.', __CLASS__, __FUNCTION__));
×
159
    }
160

161
    /**
162
     * {@inheritdoc}
163
     */
164
    public function setMultiple($values, $ttl = null): bool
165
    {
166
        throw new \Exception(\sprintf('%s::%s not yet implemented.', __CLASS__, __FUNCTION__));
×
167
    }
168

169
    /**
170
     * {@inheritdoc}
171
     */
172
    public function deleteMultiple($keys): bool
173
    {
174
        throw new \Exception(\sprintf('%s::%s not yet implemented.', __CLASS__, __FUNCTION__));
×
175
    }
176

177
    /**
178
     * Creates key from a string: "$name|uniqid__HASH__VERSION".
179
     * $name is optional to add a human readable name to the key.
180
     */
181
    public function createKey(?string $name, string $value): string
182
    {
183
        $hash = hash('md5', $value);
1✔
184
        $name = $name ? self::sanitizeKey($name) : $hash;
1✔
185

186
        return \sprintf('%s__%s__%s', $name, $hash, $this->builder->getVersion());
1✔
187
    }
188

189
    /**
190
     * Creates key from an Asset: "$path_$ext_$tags__HASH__VERSION".
191
     */
192
    public function createKeyFromAsset(Asset $asset, ?array $tags = null): string
193
    {
194
        $t = $tags;
1✔
195
        $tags = [];
1✔
196

197
        if ($t !== null) {
1✔
198
            ksort($t);
1✔
199
            foreach ($t as $key => $value) {
1✔
200
                switch (\gettype($value)) {
1✔
201
                    case 'boolean':
1✔
202
                        if ($value === true) {
1✔
203
                            $tags[] = $key;
1✔
204
                        }
205
                        break;
1✔
206
                    case 'string':
1✔
207
                    case 'integer':
1✔
208
                        if (!empty($value)) {
1✔
209
                            $tags[] = substr($key, 0, 1) . $value;
1✔
210
                        }
211
                        break;
1✔
212
                }
213
            }
214
        }
215

216
        $tagsInline = implode('_', $tags);
1✔
217
        $name = "{$asset['_path']}_{$asset['ext']}_$tagsInline";
1✔
218

219
        return $this->createKey($name, $asset['content'] ?? '');
1✔
220
    }
221

222
    /**
223
     * Creates key from a file: "RelativePathname__MD5".
224
     *
225
     * @throws RuntimeException
226
     */
227
    public function createKeyFromFile(\Symfony\Component\Finder\SplFileInfo $file): string
228
    {
229
        if (false === $content = Util\File::fileGetContents($file->getRealPath())) {
1✔
230
            throw new RuntimeException(\sprintf('Can\'t create cache key for "%s".', $file));
×
231
        }
232

233
        return $this->createKey($file->getRelativePathname(), $content);
1✔
234
    }
235

236
    /**
237
     * Clear cache by pattern.
238
     */
239
    public function clearByPattern(string $pattern): int
240
    {
241
        try {
242
            if (!Util\File::getFS()->exists($this->cacheDir)) {
×
243
                throw new RuntimeException(\sprintf('The cache directory "%s" does not exists.', $this->cacheDir));
×
244
            }
245
            $fileCount = 0;
×
246
            $iterator = new \RecursiveIteratorIterator(
×
247
                new \RecursiveDirectoryIterator($this->cacheDir),
×
248
                \RecursiveIteratorIterator::SELF_FIRST
×
249
            );
×
250
            foreach ($iterator as $file) {
×
251
                if ($file->isFile()) {
×
252
                    if (preg_match('/' . $pattern . '/i', $file->getPathname())) {
×
253
                        Util\File::getFS()->remove($file->getPathname());
×
254
                        $fileCount++;
×
255
                        $this->builder->getLogger()->debug(\sprintf('Cache removed: "%s"', Util\File::getFS()->makePathRelative($file->getPathname(), $this->builder->getConfig()->getCachePath())));
×
256
                    }
257
                }
258
            }
259
        } catch (\Exception $e) {
×
260
            $this->builder->getLogger()->error($e->getMessage());
×
261

262
            return 0;
×
263
        }
264

265
        return $fileCount;
×
266
    }
267

268
    /**
269
     * Returns cache content file pathname from path.
270
     */
271
    public function getContentFilePathname(string $path): string
272
    {
273
        $path = str_replace(['https://', 'http://'], '', $path); // remove protocol (if URL)
1✔
274

275
        return Util::joinFile($this->cacheDir, 'files', $path);
1✔
276
    }
277

278
    /**
279
     * Returns cache file pathname from key.
280
     */
281
    private function getFilePathname(string $key): string
282
    {
283
        return Util::joinFile($this->cacheDir, "$key.ser");
1✔
284
    }
285

286
    /**
287
     * Prepares and validate $key.
288
     */
289
    public static function sanitizeKey(string $key): string
290
    {
291
        $key = str_replace(['https://', 'http://'], '', $key); // remove protocol (if URL)
1✔
292
        $key = Page::slugify($key);                            // slugify
1✔
293
        $key = trim($key, '/');                                // remove leading/trailing slashes
1✔
294
        $key = str_replace(['\\', '/'], ['-', '-'], $key);     // replace slashes by hyphens
1✔
295
        $key = substr($key, 0, 200);                           // truncate to 200 characters (NTFS filename length limit is 255 characters)
1✔
296

297
        return $key;
1✔
298
    }
299

300
    /**
301
     * Removes previous cache files.
302
     */
303
    private function prune(string $key): bool
304
    {
305
        try {
306
            $keyAsArray = explode('__', self::sanitizeKey($key));
1✔
307
            // if 2 or more parts (with hash), remove all files with the same first part
308
            // pattern: `name__hash__version`
309
            if (!empty($keyAsArray[0]) && \count($keyAsArray) >= 2) {
1✔
310
                $pattern = Util::joinFile($this->cacheDir, $keyAsArray[0]) . '*';
1✔
311
                foreach (glob($pattern) ?: [] as $filename) {
1✔
312
                    Util\File::getFS()->remove($filename);
1✔
313
                    $this->builder->getLogger()->debug(\sprintf('Cache removed: "%s"', Util\File::getFS()->makePathRelative($filename, $this->builder->getConfig()->getCachePath())));
1✔
314
                }
315
            }
316
        } catch (\Exception $e) {
×
317
            $this->builder->getLogger()->error($e->getMessage());
×
318

319
            return false;
×
320
        }
321

322
        return true;
1✔
323
    }
324

325
    /**
326
     * Convert the various expressions of a TTL value into duration in seconds.
327
     */
328
    protected function duration(int|\DateInterval $ttl): int
329
    {
330
        if (\is_int($ttl)) {
×
331
            return $ttl;
×
332
        }
333
        if ($ttl instanceof \DateInterval) {
×
334
            return (int) $ttl->d * 86400 + $ttl->h * 3600 + $ttl->i * 60 + $ttl->s;
×
335
        }
336

337
        throw new \InvalidArgumentException('TTL values must be int or \DateInterval');
×
338
    }
339

340
    /**
341
     * Removes the cache content file.
342
     */
343
    protected function deleteContentFile(string $path): bool
344
    {
345
        try {
346
            Util\File::getFS()->remove($this->getContentFilePathname($path));
×
347
        } catch (\Exception $e) {
×
348
            $this->builder->getLogger()->error($e->getMessage());
×
349

350
            return false;
×
351
        }
352

353
        return true;
×
354
    }
355
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc