Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 99
0.00% covered (danger)
0.00%
0 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
Manager
0.00% covered (danger)
0.00%
0 / 99
0.00% covered (danger)
0.00%
0 / 11
1260
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 getCache
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 getCacheDir
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
42
 getCacheList
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 getNonPersistentCacheList
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 hasDirectoryCreationError
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 addDownloaderCache
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 addLanguageCacheForTheme
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 ensureFileCache
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 createNoCache
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 createFileCache
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
132
1<?php
2
3/**
4 * VuFind Cache Manager
5 *
6 * PHP version 8
7 *
8 * Copyright (C) Villanova University 2007
9 * Copyright (C) Leipzig University Library <info@ub.uni-leipzig.de> 2018
10 * Copyright (C) The National Library of Finland 2024
11 *
12 * This program is free software; you can redistribute it and/or modify
13 * it under the terms of the GNU General Public License version 2,
14 * as published by the Free Software Foundation.
15 *
16 * This program is distributed in the hope that it will be useful,
17 * but WITHOUT ANY WARRANTY; without even the implied warranty of
18 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
19 * GNU General Public License for more details.
20 *
21 * You should have received a copy of the GNU General Public License
22 * along with this program; if not, write to the Free Software
23 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
24 *
25 * @category VuFind
26 * @package  Cache
27 * @author   Demian Katz <demian.katz@villanova.edu>
28 * @author   Sebastian Kehr <kehr@ub.uni-leipzig.de>
29 * @author   Ere Maijala <ere.maijala@helsinki.fi>
30 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
31 * @link     https://vufind.org Main Page
32 */
33
34namespace VuFind\Cache;
35
36use Laminas\Cache\Service\StorageAdapterFactory;
37use Laminas\Cache\Storage\StorageInterface;
38use Laminas\Config\Config;
39
40use function dirname;
41use function is_array;
42use function strlen;
43
44/**
45 * VuFind Cache Manager
46 *
47 * Creates caches based on configuration
48 *
49 * @category VuFind
50 * @package  Cache
51 * @author   Demian Katz <demian.katz@villanova.edu>
52 * @author   Sebastian Kehr <kehr@ub.uni-leipzig.de>
53 * @author   Ere Maijala <ere.maijala@helsinki.fi>
54 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
55 * @link     https://vufind.org Main Page
56 */
57class Manager
58{
59    /**
60     * Default configuration settings.
61     *
62     * @var array
63     */
64    protected $defaults;
65
66    /**
67     * Was there a problem building cache directories?
68     *
69     * @var bool
70     */
71    protected $directoryCreationError = false;
72
73    /**
74     * Settings used to generate cache objects.
75     *
76     * @var array
77     */
78    protected $cacheSettings = [];
79
80    /**
81     * Actual cache objects generated from settings.
82     *
83     * @var StorageInterface[]
84     */
85    protected $caches = [];
86
87    /**
88     * Factory for creating storage adapters.
89     *
90     * @var StorageAdapterFactory
91     */
92    protected $factory;
93
94    /**
95     * Cache configuration.
96     *
97     * Following settings are supported:
98     *
99     *   cliOverride   Set to false to not allow cache directory override in CLI mode (optional, true by default)
100     *   directory     Cache directory (required)
101     *   options       Array of cache options (optional, e.g. disabled, ttl)
102     *   persistent    Set to true to disable clearing of the cache by default with the admin API clearCache command
103     *                 (optional, false by default)
104     *
105     * @var array
106     */
107    protected $cacheSpecs = [
108        'browscap' => [
109            'cliOverride' => false,
110            'directory' => 'browscap',
111            'options' => [
112                'ttl' => 0, // no expiration - cache is updated with console util/browscap
113                'keyPattern' => '/^[a-z0-9_\+\-\.]*$/Di',
114            ],
115            'persistent' => true,
116        ],
117        'config' => [
118            'directory' => 'configs',
119        ],
120        'cover' => [
121            'directory' => 'covers',
122            'persistent' => true,
123        ],
124        'language' => [
125            'directory' => 'languages',
126        ],
127        'object' => [
128            'directory' => 'objects',
129        ],
130        'public' => [
131            'directory' => 'public',
132        ],
133        'searchspecs' => [
134            'directory' => 'searchspecs',
135        ],
136        'yaml' => [
137            'directory' => 'yamls',
138        ],
139    ];
140
141    /**
142     * Constructor
143     *
144     * @param Config                $config       Main VuFind configuration
145     * @param Config                $searchConfig Search configuration
146     * @param StorageAdapterFactory $factory      Cache storage adapter factory
147     */
148    public function __construct(
149        Config $config,
150        Config $searchConfig,
151        StorageAdapterFactory $factory
152    ) {
153        $this->factory = $factory;
154
155        // $config and $config->Cache are Laminas\Config\Config objects
156        // $cache is created immutable, so get the array, it will be modified
157        // downstream.
158        $this->defaults = $config->Cache?->toArray() ?? [];
159
160        // Configure search specs cache based on config settings:
161        $searchCacheType = $searchConfig->Cache->type ?? false;
162        switch ($searchCacheType) {
163            case 'File':
164                // Default
165                break;
166            case false:
167                $this->cacheSpecs['searchspecs']['options']['disabled'] = true;
168                break;
169            default:
170                throw new \Exception("Unsupported cache setting: $searchCacheType");
171        }
172    }
173
174    /**
175     * Retrieve the specified cache object.
176     *
177     * @param string      $name      Name of the requested cache.
178     * @param string|null $namespace Optional namespace to use. Defaults to the
179     * value of $name.
180     *
181     * @return StorageInterface
182     * @throws \Exception
183     */
184    public function getCache($name, $namespace = null)
185    {
186        $this->ensureFileCache($name);
187        $namespace ??= $name;
188        $key = "$name:$namespace";
189
190        if (!isset($this->caches[$key])) {
191            if (!isset($this->cacheSettings[$name])) {
192                throw new \Exception('Requested unknown cache: ' . $name);
193            }
194            $settings = $this->cacheSettings[$name];
195            $settings['options']['namespace'] = $namespace;
196            $this->caches[$key]
197                = $this->factory->createFromArrayConfiguration($settings);
198        }
199
200        return $this->caches[$key];
201    }
202
203    /**
204     * Get the path to the directory containing VuFind's cache data.
205     *
206     * @param bool $allowCliOverride If true, use a different cache subdirectory
207     * for CLI mode; otherwise, share the web directories.
208     *
209     * @return string
210     */
211    public function getCacheDir($allowCliOverride = true)
212    {
213        if (isset($this->defaults['cache_dir'])) {
214            // cache_dir setting in config.ini is obsolete
215            throw new \Exception(
216                'Obsolete cache_dir setting found in config.ini - please use '
217                . 'Apache environment variable VUFIND_CACHE_DIR in '
218                . 'httpd-vufind.conf instead.'
219            );
220        }
221
222        if (strlen(LOCAL_CACHE_DIR) > 0) {
223            $dir = LOCAL_CACHE_DIR . '/';
224        } elseif (strlen(LOCAL_OVERRIDE_DIR) > 0) {
225            $dir = LOCAL_OVERRIDE_DIR . '/cache/';
226        } else {
227            $dir = APPLICATION_PATH . '/data/cache/';
228        }
229
230        // Use separate cache dir in CLI mode to avoid permission issues:
231        if ($allowCliOverride && PHP_SAPI == 'cli') {
232            $dir .= 'cli/';
233        }
234
235        return $dir;
236    }
237
238    /**
239     * Get the names of all available caches.
240     *
241     * @return array
242     */
243    public function getCacheList()
244    {
245        return array_unique(
246            [
247                ...array_keys($this->cacheSpecs),
248                ...array_keys($this->cacheSettings),
249            ]
250        );
251    }
252
253    /**
254     * Get the names of all non-persistent caches (ones that can be cleared).
255     *
256     * @return array
257     */
258    public function getNonPersistentCacheList(): array
259    {
260        $result = [];
261        foreach ($this->getCacheList() as $cache) {
262            if (!($this->cacheSpecs[$cache]['persistent'] ?? false)) {
263                $result[] = $cache;
264            }
265        }
266        return $result;
267    }
268
269    /**
270     * Check if there have been problems creating directories.
271     *
272     * @return bool
273     */
274    public function hasDirectoryCreationError()
275    {
276        return $this->directoryCreationError;
277    }
278
279    /**
280     * Create a downloader-specific file cache.
281     *
282     * @param string $downloaderName Name of the downloader.
283     * @param array  $opts           Cache options.
284     *
285     * @return string
286     */
287    public function addDownloaderCache($downloaderName, $opts = [])
288    {
289        $cacheName = 'downloader-' . $downloaderName;
290        $this->createFileCache(
291            $cacheName,
292            $this->getCacheDir(),
293            $opts
294        );
295        return $cacheName;
296    }
297
298    /**
299     * Create a new file cache for the given theme name if necessary. Return
300     * the name of the cache.
301     *
302     * @param string $themeName Name of the theme
303     *
304     * @return string
305     */
306    public function addLanguageCacheForTheme($themeName)
307    {
308        $cacheName = 'languages-' . $themeName;
309        $this->createFileCache(
310            $cacheName,
311            $this->getCacheDir() . 'languages/' . $themeName
312        );
313        return $cacheName;
314    }
315
316    /**
317     * Ensure that a file cache is properly set up
318     *
319     * @param string $name Cache name
320     *
321     * @return void
322     */
323    protected function ensureFileCache(string $name): void
324    {
325        // Use $this->cacheSettings to determine if $this->createFileCache() has been called yet:
326        if (!isset($this->cacheSettings[$name]) && $config = $this->cacheSpecs[$name] ?? null) {
327            $base = $this->getCacheDir($config['cliOverride'] ?? true);
328            $this->createFileCache($name, $base . $config['directory'], $config['options'] ?? []);
329        }
330    }
331
332    /**
333     * Create a "no-cache" setting.
334     *
335     * @param string $cacheName Name of "no cache" to create
336     *
337     * @return void
338     */
339    protected function createNoCache($cacheName)
340    {
341        $this->cacheSettings[$cacheName] = [
342            'adapter' => \Laminas\Cache\Storage\Adapter\BlackHole::class,
343            'options' => [],
344        ];
345    }
346
347    /**
348     * Add a file cache to the manager and ensure that necessary directory exists.
349     *
350     * @param string $cacheName    Name of new cache to create
351     * @param string $dirName      Directory to use for storage
352     * @param array  $overrideOpts Options to override default values.
353     *
354     * @return void
355     */
356    protected function createFileCache($cacheName, $dirName, $overrideOpts = [])
357    {
358        $opts = array_merge($this->defaults, $overrideOpts);
359        if ($opts['disabled'] ?? false) {
360            $this->createNoCache($cacheName);
361            return;
362        } else {
363            // Laminas does not support "disabled = false"; unset to avoid error.
364            unset($opts['disabled']);
365        }
366
367        if (!is_dir($dirName)) {
368            if (isset($opts['umask'])) {
369                // convert umask from string
370                $umask = octdec($opts['umask']);
371                // validate
372                if ($umask & 0o700) {
373                    throw new \Exception(
374                        'Invalid umask: ' . $opts['umask']
375                        . '; need permission to execute, read and write by owner'
376                    );
377                }
378                umask($umask);
379            }
380            if (isset($opts['dir_permission'])) {
381                $dir_perm = octdec($opts['dir_permission']);
382            } else {
383                // 0777 is chmod default, use if dir_permission is not explicitly set
384                $dir_perm = 0o777;
385            }
386            // Make sure cache parent directory and directory itself exist:
387            $parentDir = dirname($dirName);
388            if (!is_dir($parentDir) && !@mkdir($parentDir, $dir_perm)) {
389                $this->directoryCreationError = true;
390            }
391            if (!@mkdir($dirName, $dir_perm)) {
392                $this->directoryCreationError = true;
393            }
394        }
395        if (empty($opts)) {
396            $opts = ['cache_dir' => $dirName];
397        } elseif (is_array($opts)) {
398            // If VUFIND_CACHE_DIR was set in the environment, the cache-specific
399            // name should have been appended to it to create the value $dirName.
400            $opts['cache_dir'] = $dirName;
401        } else {
402            // Dryrot
403            throw new \Exception('$opts is neither array nor false');
404        }
405        $this->cacheSettings[$cacheName] = [
406            'adapter' => \Laminas\Cache\Storage\Adapter\Filesystem::class,
407            'options' => $opts,
408            'plugins' => [
409                ['name' => 'serializer'],
410            ],
411        ];
412    }
413}