Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 152
0.00% covered (danger)
0.00%
0 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
ConcatTrait
0.00% covered (danger)
0.00%
0 / 152
0.00% covered (danger)
0.00%
0 / 12
2550
0.00% covered (danger)
0.00%
0 / 1
 isExcludedFromConcat
n/a
0 / 0
n/a
0 / 0
0
 getFileType
n/a
0 / 0
n/a
0 / 0
0
 getResourceFilePath
n/a
0 / 0
n/a
0 / 0
0
 setResourceFilePath
n/a
0 / 0
n/a
0 / 0
0
 getMinifier
n/a
0 / 0
n/a
0 / 0
0
 addNonce
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getType
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 enabledInConfig
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
56
 filterItems
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
42
 getResourceCacheDir
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
 getConcatenatedFilePath
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
56
 createConcatenatedFile
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 getMinifiedData
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 outputInOrder
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
56
 isMinifiable
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 isPipelineActive
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
 toString
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2
3/**
4 * Trait to add asset pipeline functionality (concatenation / minification) to
5 * a HeadLink/HeadScript-style view helper.
6 *
7 * PHP version 8
8 *
9 * Copyright (C) Villanova University 2016.
10 * Copyright (C) The National Library of Finland 2017.
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  View_Helpers
27 * @author   Demian Katz <demian.katz@villanova.edu>
28 * @author   Ere Maijala <ere.maijala@helsinki.fi>
29 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
30 * @link     https://vufind.org/wiki/development Wiki
31 */
32
33namespace VuFindTheme\View\Helper;
34
35use VuFindTheme\ThemeInfo;
36
37use function count;
38use function defined;
39use function in_array;
40use function is_resource;
41
42/**
43 * Trait to add asset pipeline functionality (concatenation / minification) to
44 * a HeadLink/HeadScript-style view helper.
45 *
46 * @category VuFind
47 * @package  View_Helpers
48 * @author   Demian Katz <demian.katz@villanova.edu>
49 * @author   Ere Maijala <ere.maijala@helsinki.fi>
50 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
51 * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
52 */
53trait ConcatTrait
54{
55    /**
56     * Returns true if file should not be included in the compressed concat file
57     *
58     * @param stdClass $item Element object
59     *
60     * @return bool
61     */
62    abstract protected function isExcludedFromConcat($item);
63
64    /**
65     * Get the folder name and file extension
66     *
67     * @return string
68     */
69    abstract protected function getFileType();
70
71    /**
72     * Get the file path from the element object
73     *
74     * @param stdClass $item Element object
75     *
76     * @return string
77     */
78    abstract protected function getResourceFilePath($item);
79
80    /**
81     * Set the file path of the element object
82     *
83     * @param stdClass $item Element object
84     * @param string   $path New path string
85     *
86     * @return stdClass
87     */
88    abstract protected function setResourceFilePath($item, $path);
89
90    /**
91     * Get the minifier that can handle these file types
92     *
93     * @return minifying object like \MatthiasMullie\Minify\JS
94     */
95    abstract protected function getMinifier();
96
97    /**
98     * Add a content security policy nonce to the item
99     *
100     * @param stdClass $item Item
101     *
102     * @return void
103     *
104     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
105     */
106    protected function addNonce($item)
107    {
108        // Default implementation does nothing
109    }
110
111    /**
112     * Set the file path of the link object
113     *
114     * @param stdClass $item Link element object
115     *
116     * @return string
117     *
118     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
119     */
120    public function getType($item)
121    {
122        return 'default';
123    }
124
125    /**
126     * Should we use the asset pipeline to join files together and minify them?
127     *
128     * @var bool
129     */
130    protected $usePipeline = false;
131
132    /**
133     * Array of resource items by type, contains key as well
134     *
135     * @var array
136     */
137    protected $groups = [];
138
139    /**
140     * Future order of the concatenated file
141     *
142     * @var number
143     */
144    protected $concatIndex = null;
145
146    /**
147     * Check if config is enabled for this file type
148     *
149     * @param string|bool $config Config for current application environment
150     *
151     * @return bool
152     */
153    protected function enabledInConfig($config)
154    {
155        if ($config === false || $config == 'off') {
156            return false;
157        }
158        if (
159            $config == '*' || $config == 'on'
160            || $config == 'true' || $config === true
161        ) {
162            return true;
163        }
164        $settings = array_map('trim', explode(',', $config));
165        return in_array($this->getFileType(), $settings);
166    }
167
168    /**
169     * Initialize class properties related to concatenation of resources.
170     * All of the elements to be concatenated into groups and
171     * and those that need to remain on their own special group 'other'.
172     *
173     * @return bool True if there are items
174     */
175    protected function filterItems()
176    {
177        $this->groups = [];
178        $groupTypes = [];
179
180        $this->getContainer()->ksort();
181
182        foreach ($this as $item) {
183            if ($this->isExcludedFromConcat($item)) {
184                $this->groups[] = [
185                    'other' => true,
186                    'item' => $item,
187                ];
188                $groupTypes[] = 'other';
189                continue;
190            }
191
192            $path = $this->getFileType() . '/' . $this->getResourceFilePath($item);
193            $details = $this->themeInfo->findContainingTheme(
194                $path,
195                ThemeInfo::RETURN_ALL_DETAILS
196            );
197            // Deal with special case: $path was not found in any theme.
198            if (null === $details) {
199                $errorMsg = "Could not find file '$path' in theme files";
200                method_exists($this, 'logError')
201                    ? $this->logError($errorMsg) : error_log($errorMsg);
202                $this->groups[] = [
203                    'other' => true,
204                    'item' => $item,
205                ];
206                $groupTypes[] = 'other';
207                continue;
208            }
209
210            $type = $this->getType($item);
211            $index = array_search($type, $groupTypes);
212            if ($index === false) {
213                $this->groups[] = [
214                    'items' => [$item],
215                    'key' => $details['path'] . filemtime($details['path']),
216                ];
217                $groupTypes[] = $type;
218            } else {
219                $this->groups[$index]['items'][] = $item;
220                $this->groups[$index]['key'] .=
221                    $details['path'] . filemtime($details['path']);
222            }
223        }
224
225        return count($groupTypes) > 0;
226    }
227
228    /**
229     * Get the path to the directory where we can cache files generated by
230     * this trait. The directory will be created if it does not already exist.
231     *
232     * @return string
233     */
234    protected function getResourceCacheDir()
235    {
236        if (!defined('LOCAL_CACHE_DIR')) {
237            throw new \Exception(
238                'Asset pipeline feature depends on the LOCAL_CACHE_DIR constant.'
239            );
240        }
241        // TODO: it might be better to use \VuFind\Cache\Manager here.
242        $cacheDir = LOCAL_CACHE_DIR . '/public/';
243        if (!is_dir($cacheDir) && !file_exists($cacheDir)) {
244            if (!mkdir($cacheDir)) {
245                throw new \Exception("Unexpected problem creating cache directory: $cacheDir");
246            }
247        }
248        return $cacheDir;
249    }
250
251    /**
252     * Using the concatKey, return the path of the concatenated file.
253     * Generate if it does not yet exist.
254     *
255     * @param array $group Object containing 'key' and stdobj file 'items'
256     *
257     * @return string
258     */
259    protected function getConcatenatedFilePath($group)
260    {
261        $urlHelper = $this->getView()->plugin('url');
262
263        // Don't recompress individual files
264        if (count($group['items']) === 1) {
265            $path = $this->getResourceFilePath($group['items'][0]);
266            $details = $this->themeInfo->findContainingTheme(
267                $this->getFileType() . '/' . $path,
268                ThemeInfo::RETURN_ALL_DETAILS
269            );
270            return $urlHelper('home') . 'themes/' . $details['theme']
271                . '/' . $this->getFileType() . '/' . $path;
272        }
273        // Locate/create concatenated asset file
274        $filename = md5($group['key']) . '.min.' . $this->getFileType();
275        // Minifier uses realpath, so do that here too to make sure we're not
276        // pointing to a symlink. Otherwise the path converter won't find the correct
277        // shared directory part.
278        $concatPath = realpath($this->getResourceCacheDir()) . '/' . $filename;
279        if (!file_exists($concatPath)) {
280            $lockfile = "$concatPath.lock";
281            $handle = fopen($lockfile, 'c+');
282            if (!is_resource($handle)) {
283                throw new \Exception("Could not open lock file $lockfile");
284            }
285            if (!flock($handle, LOCK_EX)) {
286                fclose($handle);
287                throw new \Exception("Could not lock file $lockfile");
288            }
289            // Check again if file exists after acquiring the lock
290            if (!file_exists($concatPath)) {
291                try {
292                    $this->createConcatenatedFile($concatPath, $group);
293                } catch (\Exception $e) {
294                    flock($handle, LOCK_UN);
295                    fclose($handle);
296                    throw $e;
297                }
298            }
299            flock($handle, LOCK_UN);
300            fclose($handle);
301        }
302
303        return $urlHelper('home') . 'cache/' . $filename;
304    }
305
306    /**
307     * Create a concatenated file from the given group of files
308     *
309     * @param string $concatPath Resulting file path
310     * @param array  $group      Object containing 'key' and stdobj file 'items'
311     *
312     * @throws \Exception
313     * @return void
314     */
315    protected function createConcatenatedFile($concatPath, $group)
316    {
317        $data = [];
318        foreach ($group['items'] as $item) {
319            $details = $this->themeInfo->findContainingTheme(
320                $this->getFileType() . '/'
321                . $this->getResourceFilePath($item),
322                ThemeInfo::RETURN_ALL_DETAILS
323            );
324            $details['path'] = realpath($details['path']);
325            $data[] = $this->getMinifiedData($details, $concatPath);
326        }
327        // Separate each file's data with a new line so that e.g. a file
328        // ending in a comment doesn't cause the next one to also get commented out.
329        file_put_contents($concatPath, implode("\n", $data));
330    }
331
332    /**
333     * Get minified data for a file
334     *
335     * @param array  $details    File details
336     * @param string $concatPath Target path for the resulting file (used in minifier
337     * for path mapping)
338     *
339     * @throws \Exception
340     * @return string
341     */
342    protected function getMinifiedData($details, $concatPath)
343    {
344        if ($this->isMinifiable($details['path'])) {
345            $minifier = $this->getMinifier();
346            $minifier->add($details['path']);
347            $data = $minifier->execute($concatPath);
348        } else {
349            $data = file_get_contents($details['path']);
350            if (false === $data) {
351                throw new \Exception(
352                    "Could not read file {$details['path']}"
353                );
354            }
355        }
356        return $data;
357    }
358
359    /**
360     * Process and return items in index order
361     *
362     * @param string|int $indent Amount of whitespace/string to use for indention
363     *
364     * @return string
365     */
366    protected function outputInOrder($indent)
367    {
368        // Some of this logic was copied from HeadScript; it does not all apply
369        // when incorporated into HeadLink, but it has no harmful side effects.
370        $indent = (null !== $indent)
371            ? $this->getWhitespace($indent)
372            : $this->getIndent();
373
374        if ($this->view) {
375            $useCdata = $this->view->plugin('doctype')->isXhtml();
376        } else {
377            $useCdata = $this->useCdata ?? false;
378        }
379
380        $escapeStart = ($useCdata) ? '//<![CDATA[' : '//<!--';
381        $escapeEnd   = ($useCdata) ? '//]]>' : '//-->';
382
383        $output = [];
384        foreach ($this->groups as $group) {
385            if (isset($group['other'])) {
386                /**
387                 * PHPStan doesn't like this because of incompatible itemToString
388                 * signatures in HeadLink/HeadScript, but it is safe to use because
389                 * the extra parameters will be ignored appropriately.
390                 *
391                 * @phpstan-ignore-next-line
392                 */
393                $output[] = $this->itemToString(
394                    $group['item'],
395                    $indent,
396                    $escapeStart,
397                    $escapeEnd
398                );
399            } else {
400                // Note that we  use parent::itemToString() below instead of
401                // $this->itemToString() to bypass VuFind logic that determines
402                // file paths within the theme (not appropriate for concatenated
403                // files, which are stored in a theme-independent cache).
404                $path = $this->getConcatenatedFilePath($group);
405                $item = $this->setResourceFilePath($group['items'][0], $path);
406                $this->addNonce($item);
407                /**
408                 * PHPStan doesn't like this because of incompatible itemToString
409                 * signatures in HeadLink/HeadScript, but it is safe to use because
410                 * the extra parameters will be ignored appropriately.
411                 *
412                 * @phpstan-ignore-next-line
413                 */
414                $output[] = parent::itemToString(
415                    $item,
416                    $indent,
417                    $escapeStart,
418                    $escapeEnd
419                );
420            }
421        }
422
423        return $indent . implode(
424            $this->escape($this->getSeparator()) . $indent,
425            $output
426        );
427    }
428
429    /**
430     * Check if a file is minifiable i.e. does not have a pattern that denotes it's
431     * already minified
432     *
433     * @param string $filename File name
434     *
435     * @return bool
436     */
437    protected function isMinifiable($filename)
438    {
439        $basename = basename($filename);
440        return preg_match('/\.min\.(js|css)/', $basename) === 0;
441    }
442
443    /**
444     * Can we use the asset pipeline?
445     *
446     * @return bool
447     */
448    protected function isPipelineActive()
449    {
450        if ($this->usePipeline) {
451            try {
452                $cacheDir = $this->getResourceCacheDir();
453            } catch (\Exception $e) {
454                $this->usePipeline = $cacheDir = false;
455                error_log($e->getMessage());
456            }
457            if ($cacheDir && !is_writable($cacheDir)) {
458                $this->usePipeline = false;
459                error_log("Cannot write to $cacheDir; disabling asset pipeline.");
460            }
461        }
462        return $this->usePipeline;
463    }
464
465    /**
466     * Render link elements as string
467     * Customized to minify and concatenate
468     *
469     * @param string|int $indent Amount of whitespace or string to use for indention
470     *
471     * @return string
472     */
473    public function toString($indent = null)
474    {
475        // toString must not throw exception
476        try {
477            if (
478                !$this->isPipelineActive() || !$this->filterItems()
479                || count($this) == 1
480            ) {
481                return parent::toString($indent);
482            }
483
484            return $this->outputInOrder($indent);
485        } catch (\Exception $e) {
486            error_log($e->getMessage());
487        }
488
489        return '';
490    }
491}