Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
90.79% |
207 / 228 |
|
92.86% |
13 / 14 |
CRAP | |
0.00% |
0 / 1 |
CookieConsent | |
90.79% |
207 / 228 |
|
92.86% |
13 / 14 |
48.73 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
__invoke | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
render | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
2 | |||
isEnabled | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getControlledVuFindServices | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
isCategoryAccepted | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
isServiceAllowed | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
4 | |||
getConsentInformation | |
0.00% |
0 / 21 |
|
0.00% |
0 / 1 |
56 | |||
getConsentDialogConfig | |
100.00% |
149 / 149 |
|
100.00% |
1 / 1 |
15 | |||
getPlaceholders | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
2 | |||
getDescriptionPlaceholders | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
getHostName | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
getConsentRevision | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getCurrentConsent | |
100.00% |
5 / 5 |
|
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 | |
30 | namespace VuFind\View\Helper\Root; |
31 | |
32 | use VuFind\Auth\LoginTokenManager; |
33 | use VuFind\Cookie\CookieManager; |
34 | use VuFind\Date\Converter as DateConverter; |
35 | use VuFind\I18n\Translator\TranslatorAwareInterface; |
36 | use VuFind\I18n\Translator\TranslatorAwareTrait; |
37 | |
38 | use function in_array; |
39 | use 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 | */ |
50 | class 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 | } |