Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.79% covered (success)
90.79%
207 / 228
92.86% covered (success)
92.86%
13 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
CookieConsent
90.79% covered (success)
90.79%
207 / 228
92.86% covered (success)
92.86%
13 / 14
48.73
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 __invoke
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 render
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 isEnabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getControlledVuFindServices
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 isCategoryAccepted
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 isServiceAllowed
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 getConsentInformation
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
56
 getConsentDialogConfig
100.00% covered (success)
100.00%
149 / 149
100.00% covered (success)
100.00%
1 / 1
15
 getPlaceholders
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 getDescriptionPlaceholders
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 getHostName
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getConsentRevision
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCurrentConsent
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3/**
4 * CookieConsent view helper
5 *
6 * PHP version 8
7 *
8 * Copyright (C) The National Library of Finland 2022.
9 *
10 * This program is free software; you can redistribute it and/or modify
11 * it under the terms of the GNU General Public License version 2,
12 * as published by the Free Software Foundation.
13 *
14 * This program is distributed in the hope that it will be useful,
15 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17 * GNU General Public License for more details.
18 *
19 * You should have received a copy of the GNU General Public License
20 * along with this program; if not, write to the Free Software
21 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
22 *
23 * @category VuFind
24 * @package  View_Helpers
25 * @author   Ere Maijala <ere.maijala@helsinki.fi>
26 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
27 * @link     https://vufind.org Main Site
28 */
29
30namespace VuFind\View\Helper\Root;
31
32use VuFind\Auth\LoginTokenManager;
33use VuFind\Cookie\CookieManager;
34use VuFind\Date\Converter as DateConverter;
35use VuFind\I18n\Translator\TranslatorAwareInterface;
36use VuFind\I18n\Translator\TranslatorAwareTrait;
37
38use function in_array;
39use function is_string;
40
41/**
42 * CookieConsent view helper
43 *
44 * @category VuFind
45 * @package  View_Helpers
46 * @author   Ere Maijala <ere.maijala@helsinki.fi>
47 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
48 * @link     https://vufind.org Main Site
49 */
50class CookieConsent extends \Laminas\View\Helper\AbstractHelper implements TranslatorAwareInterface
51{
52    use TranslatorAwareTrait;
53
54    /**
55     * Consent cookie name
56     *
57     * @var string
58     */
59    protected $consentCookieName;
60
61    /**
62     * Consent cookie expiration time (days)
63     *
64     * @var int
65     */
66    protected $consentCookieExpiration;
67
68    /**
69     * Server name
70     *
71     * @var string
72     */
73    protected $hostName = null;
74
75    /**
76     * Constructor
77     *
78     * @param array             $config            Main configuration
79     * @param array             $consentConfig     Cookie consent configuration
80     * @param CookieManager     $cookieManager     Cookie manager
81     * @param DateConverter     $dateConverter     Date converter
82     * @param LoginTokenManager $loginTokenManager Login token manager
83     */
84    public function __construct(
85        protected array $config,
86        protected array $consentConfig,
87        protected CookieManager $cookieManager,
88        protected DateConverter $dateConverter,
89        protected LoginTokenManager $loginTokenManager
90    ) {
91        $this->consentCookieName = $this->consentConfig['CookieName'] ?? 'cc_cookie';
92        $this->consentCookieExpiration = $this->consentConfig['CookieExpiration'] ?? 182; // half a year
93    }
94
95    /**
96     * Return this object
97     *
98     * @return \VuFind\View\Helper\Root\CookieConsent
99     */
100    public function __invoke(): \VuFind\View\Helper\Root\CookieConsent
101    {
102        return $this;
103    }
104
105    /**
106     * Render cookie consent initialization script
107     *
108     * @return string
109     */
110    public function render(): string
111    {
112        if (!$this->isEnabled()) {
113            return '';
114        }
115        $params = [
116            'consentConfig' => $this->consentConfig,
117            'consentCookieName' => $this->consentCookieName,
118            'consentCookieExpiration' => $this->consentCookieExpiration,
119            'placeholders' => $this->getPlaceholders(),
120            'cookieManager' => $this->cookieManager,
121            'consentDialogConfig' => $this->getConsentDialogConfig(),
122            'controlledVuFindServices' => $this->getControlledVuFindServices(),
123        ];
124        return $this->getView()->render('Helpers/cookie-consent.phtml', $params);
125    }
126
127    /**
128     * Check if the cookie consent mechanism is enabled
129     *
130     * @return bool
131     */
132    public function isEnabled(): bool
133    {
134        return !empty($this->config['Cookies']['consent']);
135    }
136
137    /**
138     * Get controlled VuFind services (services integrated into VuFind)
139     *
140     * @return array
141     */
142    public function getControlledVuFindServices(): array
143    {
144        $controlledVuFindServices = [];
145        foreach ($this->consentConfig['Categories'] ?? [] as $name => $category) {
146            if ($serviceNames = $category['ControlVuFindServices'] ?? []) {
147                $controlledVuFindServices[$name] = [
148                    ...$controlledVuFindServices[$name] ?? [], ...$serviceNames,
149                ];
150            }
151        }
152        return $controlledVuFindServices;
153    }
154
155    /**
156     * Check if a cookie category is accepted
157     *
158     * Checks the consent cookie for accepted category information
159     *
160     * @param string $category Category
161     *
162     * @return bool
163     */
164    public function isCategoryAccepted(string $category): bool
165    {
166        if (!isset($this->consentConfig['Categories'][$category])) {
167            return false;
168        }
169        if ($consent = $this->getCurrentConsent()) {
170            return in_array($category, (array)($consent['categories'] ?? []));
171        }
172        return false;
173    }
174
175    /**
176     * Check if a VuFind service is allowed
177     *
178     * @param string $service Service
179     *
180     * @return bool
181     */
182    public function isServiceAllowed(string $service): bool
183    {
184        foreach ($this->getControlledVuFindServices() as $category => $services) {
185            if (
186                in_array($service, $services)
187                && $this->isCategoryAccepted($category)
188            ) {
189                return true;
190            }
191        }
192        return false;
193    }
194
195    /**
196     * Get information about user's given consent
197     *
198     * The following fields are guaranteed to be returned if consent has been given:
199     *
200     * - consentId            Consent ID
201     * - domain               Cookie domain
202     * - path                 Cookie path
203     * - lastConsentTimestamp Timestamp the consent was given or updated
204     * - lastConsentDateTime  Formatted date and time the consent was given or
205     *                        updated
206     * - categories           Categories allowed in the consent
207     * - categoriesTranslated Translated names of categories allowed in the consent
208     *
209     * @return ?array Associative array or null if no consent has been given or it
210     * cannot be decoded
211     */
212    public function getConsentInformation(): ?array
213    {
214        if ($result = $this->getCurrentConsent()) {
215            if (
216                !empty($result['consentId'])
217                && !empty($result['lastConsentTimestamp'])
218                && !empty($result['categories'])
219            ) {
220                $result['categories'] = (array)$result['categories'];
221                foreach ($result['categories'] as $category) {
222                    $result['categoriesTranslated'][]
223                        = $this->translate(
224                            $this->consentConfig['Categories'][$category]['Title']
225                            ?? 'Unknown'
226                        );
227                }
228                $result['lastConsentDateTime']
229                    = $this->dateConverter->convertToDisplayDateAndTime(
230                        'Y-m-d\TH:i:s.vP',
231                        str_replace('Z', '+00:00', $result['lastConsentTimestamp'])
232                    );
233                $result['domain'] = $this->cookieManager->getDomain()
234                    ?: $this->getView()->plugin('serverUrl')->getHost();
235                $result['path'] = $this->cookieManager->getPath();
236                return $result;
237            }
238        }
239        return null;
240    }
241
242    /**
243     * Get configuration for the consent dialog
244     *
245     * @return array
246     */
247    protected function getConsentDialogConfig(): array
248    {
249        $descriptionPlaceholders = $this->getDescriptionPlaceholders();
250        $categories = $this->config['Cookies']['consentCategories'] ?? '';
251        $enabledCategories = $categories ? explode(',', $categories) : ['essential'];
252        $lang = $this->getTranslatorLocale();
253        $cookieSettings = [
254            'name' => $this->consentCookieName,
255            'path' => $this->cookieManager->getPath(),
256            'expiresAfterDays' => $this->consentCookieExpiration,
257            'sameSite' => $this->cookieManager->getSameSite(),
258        ];
259        // Set domain only if we have a value for it to avoid overriding the default
260        // (i.e. window.location.hostname):
261        if ($domain = $this->cookieManager->getDomain()) {
262            $cookieSettings['domain'] = $domain;
263        }
264        $rtl = ($this->getView()->plugin('layout'))()->rtl;
265        $consentDialogConfig = [
266            'autoClearCookies' => $this->consentConfig['AutoClear'] ?? true,
267            'manageScriptTags' => $this->consentConfig['ManageScripts'] ?? true,
268            'hideFromBots' => $this->consentConfig['HideFromBots'] ?? true,
269            'cookie' => $cookieSettings,
270            'revision' => $this->getConsentRevision(),
271            'guiOptions' => [
272                'consentModal' => [
273                    'layout' => 'bar',
274                    'position' => 'bottom center',
275                    'transition' => 'slide',
276                ],
277                'preferencesModal' => [
278                    'layout' => 'box',
279                    'transition' => 'none',
280                ],
281            ],
282            'language' => [
283                'default' => $lang,
284                'autoDetect' => false,
285                'rtl' => $rtl,
286                'translations' => [
287                    $lang => [
288                        'consentModal' => [
289                            'title' => $this->translate(
290                                'CookieConsent::popup_title_html'
291                            ),
292                            'description' => $this->translate(
293                                'CookieConsent::popup_description_html',
294                                $descriptionPlaceholders
295                            ),
296                            'revisionMessage' => $this->translate(
297                                'CookieConsent::popup_revision_message_html'
298                            ),
299                            'acceptAllBtn' => $this->translate(
300                                'CookieConsent::Accept All Cookies'
301                            ),
302                            'acceptNecessaryBtn' => $this->translate(
303                                'CookieConsent::Accept Only Essential Cookies'
304                            ),
305                        ],
306                        'preferencesModal' => [
307                            'title' => $this->translate(
308                                'CookieConsent::cookie_settings_html'
309                            ),
310                            'savePreferencesBtn' => $this->translate(
311                                'CookieConsent::Save Settings'
312                            ),
313                            'acceptAllBtn' => $this->translate(
314                                'CookieConsent::Accept All Cookies'
315                            ),
316                            'acceptNecessaryBtn' => $this->translate(
317                                'CookieConsent::Accept Only Essential Cookies'
318                            ),
319                            'closeIconLabel' => $this->translate('close'),
320                            'flipButtons' => $rtl,
321                            'sections' => [
322                                [
323                                    'description' => $this->translate(
324                                        'CookieConsent::category_description_html',
325                                        $descriptionPlaceholders
326                                    ),
327                                ],
328                            ],
329                        ],
330                    ],
331                ],
332            ],
333        ];
334        $headers = [
335            'name' => $this->translate('CookieConsent::Name'),
336            'domain' => $this->translate('CookieConsent::Domain'),
337            'desc' => $this->translate('CookieConsent::Description'),
338            'exp' => $this->translate('CookieConsent::Expiration'),
339        ];
340        $categoryData = $this->consentConfig['Categories'] ?? [];
341        foreach ($categoryData as $categoryId => $categoryConfig) {
342            if ($enabledCategories && !in_array($categoryId, $enabledCategories)) {
343                continue;
344            }
345            $consentDialogConfig['categories'][$categoryId] = [
346                'enabled' => ($categoryConfig['Essential'] ?? false)
347                    || ($categoryConfig['DefaultEnabled'] ?? false),
348                'readOnly' => $categoryConfig['Essential'] ?? false,
349            ];
350            $section = [
351                'title' => $this->translate($categoryConfig['Title'] ?? ''),
352                'description'
353                    => $this->translate($categoryConfig['Description'] ?? ''),
354                'linkedCategory' => $categoryId,
355                'cookieTable' => [
356                    'headers' => $headers,
357                ],
358            ];
359            foreach ($categoryConfig['Cookies'] ?? [] as $cookie) {
360                $name = $cookie['Name'];
361                if (!empty($cookie['ThirdParty'])) {
362                    $name .= ' ('
363                        . $this->translate('CookieConsent::third_party_html') . ')';
364                }
365                switch ($cookie['Expiration']) {
366                    case 'never':
367                        $expiration
368                            = $this->translate('CookieConsent::expiration_never');
369                        break;
370                    case 'session':
371                        $expiration
372                            = $this->translate('CookieConsent::expiration_session');
373                        break;
374                    default:
375                        if (!empty($cookie['ExpirationUnit'])) {
376                            $expiration = ' ' . $this->translate(
377                                'CookieConsent::expiration_unit_'
378                                . $cookie['ExpirationUnit'],
379                                ['%%expiration%%' => $cookie['Expiration']],
380                                $cookie['Expiration'] . ' '
381                                . $cookie['ExpirationUnit']
382                            );
383                        } else {
384                            $expiration = $cookie['Expiration'];
385                        }
386                }
387                $section['cookieTable']['body'][] = [
388                    'name' => $name,
389                    'domain' => $cookie['Domain'],
390                    'desc' => $this->translate($cookie['Description'] ?? ''),
391                    'exp' => $expiration,
392                ];
393            }
394            if ($autoClear = $categoryConfig['AutoClearCookies'] ?? []) {
395                $section['autoClear']['cookies'] = $autoClear;
396            }
397
398            $translationsElem = &$consentDialogConfig['language']['translations'];
399            $translationsElem[$lang]['preferencesModal']['sections'][] = $section;
400            unset($translationsElem);
401        }
402        // Replace placeholders:
403        $placeholders = $this->getPlaceholders();
404        $placeholderSearch = array_keys($placeholders);
405        $placeholderReplace =  array_values($placeholders);
406        array_walk_recursive(
407            $consentDialogConfig,
408            function (&$value) use ($placeholderSearch, $placeholderReplace) {
409                if (is_string($value)) {
410                    $value = str_replace(
411                        $placeholderSearch,
412                        $placeholderReplace,
413                        $value
414                    );
415                }
416            }
417        );
418
419        return $consentDialogConfig;
420    }
421
422    /**
423     * Get placeholders for strings
424     *
425     * @return array
426     */
427    protected function getPlaceholders(): array
428    {
429        return [
430            '{{consent_cookie_name}}' => $this->consentCookieName,
431            '{{consent_cookie_expiration}}' => $this->consentCookieExpiration,
432            '{{current_host_name}}' => $this->getHostName(),
433            '{{vufind_cookie_domain}}' => $this->cookieManager->getDomain()
434                ?: $this->getHostName(),
435            '{{vufind_session_cookie}}' => $this->cookieManager->getSessionName(),
436            '{{vufind_login_token_cookie_name}}' => $this->loginTokenManager->getCookieName(),
437            '{{vufind_login_token_cookie_expiration}}' => $this->loginTokenManager->getCookieLifetime(),
438        ];
439    }
440
441    /**
442     * Get placeholders for description translations
443     *
444     * @return array
445     */
446    protected function getDescriptionPlaceholders(): array
447    {
448        $root = rtrim(($this->getView()->plugin('url'))('home'), '/');
449        $escapeHtmlAttr = $this->getView()->plugin('escapeHtmlAttr');
450        return [
451            '%%siteRoot%%' => $root,
452            '%%siteRootAttr%%' => $escapeHtmlAttr($root),
453        ];
454    }
455
456    /**
457     * Get current host name
458     *
459     * @return string
460     */
461    protected function getHostName(): string
462    {
463        if (null === $this->hostName) {
464            $this->hostName = $this->getView()->plugin('serverUrl')->getHost();
465        }
466        return $this->hostName;
467    }
468
469    /**
470     * Get current consent revision
471     *
472     * @return int
473     */
474    protected function getConsentRevision(): int
475    {
476        return (int)($this->config['Cookies']['consentRevision'] ?? 0);
477    }
478
479    /**
480     * Get current consent data
481     *
482     * @return array
483     */
484    protected function getCurrentConsent(): array
485    {
486        if ($consentJson = $this->cookieManager->get($this->consentCookieName)) {
487            if ($consent = json_decode($consentJson, true)) {
488                if (($consent['revision'] ?? null) === $this->getConsentRevision()) {
489                    return $consent;
490                }
491            }
492        }
493        return [];
494    }
495}