PluginProbe
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder / 2.14.0
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder v2.14.0
2.14.0 2.13.0 2.13.1 2.12.0 2.11.1 2.11.0 2.10.0 2.9.0 2.7.4 2.7.5 2.7.6 2.7.7 2.8.0 2.8.1 2.9.1 trunk 1.0 1.0-beta1 1.0-beta2 1.0-beta3 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 All 81 releases
ablocks / addons / cookie-consent / helper.php

helper.php in aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder 2.14.0, at addons/cookie-consent/helper.php

660 lines 22.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 namespace ABlocksCookieConsent;
3
4 if ( ! defined( 'ABSPATH' ) ) {
5 exit;
6 }
7
8 /**
9 * Settings access and the shipped defaults.
10 *
11 * Everything the addon does is driven from one option so the whole
12 * configuration can be read in a single query on the front end, and so the
13 * admin screen has exactly one thing to save.
14 */
15 class Helper {
16
17 /**
18 * Cached decoded settings for this request.
19 *
20 * @var array|null
21 */
22 private static $settings = null;
23
24 /**
25 * The categories the plugin ships with.
26 *
27 * `necessary` is locked: it cannot be refused, because refusing it would
28 * mean refusing the site. Everything else defaults to denied, which is the
29 * only default an opt-in regime allows.
30 */
31 public static function default_categories() {
32 return [
33 [
34 'slug' => 'necessary',
35 'label' => __( 'Strictly necessary', 'ablocks' ),
36 'description' => __( 'Required for the site to work — security, load balancing, and remembering your cookie choice. These cannot be switched off.', 'ablocks' ),
37 'locked' => true,
38 'enabled' => true,
39 // Only the cookie this addon sets itself is listed. What else a
40 // site stores depends on what it runs, and a pre-filled list of
41 // cookies that are not actually set is worse than an empty one:
42 // it is a disclosure that is wrong.
43 'cookies' => [
44 [
45 'name' => 'ablocks_consent',
46 'provider' => '',
47 'duration' => __( '1 year', 'ablocks' ),
48 'purpose' => __( 'Stores which cookie categories you agreed to, so you are not asked again.', 'ablocks' ),
49 ],
50 ],
51 ],
52 [
53 'slug' => 'functional',
54 'label' => __( 'Functional', 'ablocks' ),
55 'description' => __( 'Remember choices you make, such as language or region, and enable embedded content.', 'ablocks' ),
56 'locked' => false,
57 'enabled' => true,
58 'cookies' => [],
59 ],
60 [
61 'slug' => 'analytics',
62 'label' => __( 'Analytics', 'ablocks' ),
63 'description' => __( 'Help us understand how the site is used, so we can improve it. The data is aggregated.', 'ablocks' ),
64 'locked' => false,
65 'enabled' => true,
66 'cookies' => [],
67 ],
68 [
69 'slug' => 'marketing',
70 'label' => __( 'Marketing', 'ablocks' ),
71 'description' => __( 'Used to show you relevant advertising on this site and elsewhere, and to measure how it performs.', 'ablocks' ),
72 'locked' => false,
73 'enabled' => true,
74 'cookies' => [],
75 ],
76 ];
77 }
78
79 /**
80 * The rules that classify a script as belonging to a category.
81 *
82 * Only well-known tags ship enabled. A rule that fires on something it does
83 * not understand takes a site down — a blocked checkout or chat widget is a
84 * worse outcome than an ungated analytics tag — so the shipped list is
85 * short, specific, and visible in the admin screen for editing.
86 *
87 * `src` matches the script's URL; `inline` matches the contents of an inline
88 * script. Both are regular-expression bodies, matched case-insensitively.
89 */
90 public static function default_rules() {
91 return [
92 [
93 'id' => 'google-tag-manager',
94 'label' => 'Google Tag Manager',
95 'category' => 'analytics',
96 'src' => 'googletagmanager\.com/gtm\.js',
97 'inline' => 'googletagmanager\.com/gtm\.js|\(window,document,[\'"]script[\'"],[\'"]dataLayer',
98 'handles' => '',
99 'enabled' => true,
100 ],
101 [
102 'id' => 'google-analytics',
103 'label' => 'Google Analytics (GA4 / gtag.js)',
104 'category' => 'analytics',
105 'src' => 'googletagmanager\.com/gtag/js|google-analytics\.com/(analytics|ga)\.js',
106 'inline' => 'gtag\s*\(\s*[\'"]config[\'"]|GoogleAnalyticsObject',
107 'handles' => '',
108 'enabled' => true,
109 ],
110 [
111 'id' => 'google-ads',
112 'label' => 'Google Ads / DoubleClick',
113 'category' => 'marketing',
114 'src' => 'googleadservices\.com|doubleclick\.net|googlesyndication\.com',
115 'inline' => '',
116 'handles' => '',
117 'enabled' => true,
118 ],
119 [
120 'id' => 'meta-pixel',
121 'label' => 'Meta (Facebook) Pixel',
122 'category' => 'marketing',
123 'src' => 'connect\.facebook\.net/[^/]+/fbevents\.js',
124 'inline' => 'fbq\s*\(|connect\.facebook\.net/[^/]+/fbevents\.js',
125 'handles' => '',
126 'enabled' => true,
127 ],
128 [
129 'id' => 'hotjar',
130 'label' => 'Hotjar',
131 'category' => 'analytics',
132 'src' => 'static\.hotjar\.com|script\.hotjar\.com',
133 'inline' => 'hjSettings|_hjSettings',
134 'handles' => '',
135 'enabled' => true,
136 ],
137 [
138 'id' => 'microsoft-clarity',
139 'label' => 'Microsoft Clarity',
140 'category' => 'analytics',
141 'src' => 'clarity\.ms',
142 'inline' => 'clarity\.ms/tag',
143 'handles' => '',
144 'enabled' => true,
145 ],
146 [
147 'id' => 'linkedin',
148 'label' => 'LinkedIn Insight Tag',
149 'category' => 'marketing',
150 'src' => 'snap\.licdn\.com',
151 'inline' => '_linkedin_partner_id',
152 'handles' => '',
153 'enabled' => true,
154 ],
155 [
156 'id' => 'tiktok',
157 'label' => 'TikTok Pixel',
158 'category' => 'marketing',
159 'src' => 'analytics\.tiktok\.com',
160 'inline' => 'ttq\.load|analytics\.tiktok\.com',
161 'handles' => '',
162 'enabled' => true,
163 ],
164 [
165 'id' => 'x-ads',
166 'label' => 'X (Twitter) Pixel',
167 'category' => 'marketing',
168 'src' => 'static\.ads-twitter\.com',
169 'inline' => 'twq\s*\(',
170 'handles' => '',
171 'enabled' => true,
172 ],
173 [
174 'id' => 'pinterest',
175 'label' => 'Pinterest Tag',
176 'category' => 'marketing',
177 'src' => 's\.pinimg\.com',
178 'inline' => 'pintrk\s*\(',
179 'handles' => '',
180 'enabled' => true,
181 ],
182 [
183 'id' => 'matomo',
184 'label' => 'Matomo',
185 'category' => 'analytics',
186 'src' => 'matomo\.js|piwik\.js',
187 'inline' => '_paq\.push',
188 'handles' => '',
189 'enabled' => true,
190 ],
191 [
192 'id' => 'intercom',
193 'label' => 'Intercom',
194 'category' => 'functional',
195 'src' => 'widget\.intercom\.io|js\.intercomcdn\.com',
196 'inline' => 'intercomSettings',
197 'handles' => '',
198 'enabled' => false,
199 ],
200 [
201 'id' => 'crisp',
202 'label' => 'Crisp Chat',
203 'category' => 'functional',
204 'src' => 'client\.crisp\.chat',
205 'inline' => '\$crisp',
206 'handles' => '',
207 'enabled' => false,
208 ],
209 ];
210 }
211
212 /**
213 * The banner's own defaults. Deliberately plain: a site owner will restyle
214 * it, and a default that already looks "designed" is harder to restyle than
215 * one that looks neutral.
216 */
217 public static function default_banner() {
218 return [
219 'layout' => 'bar',
220 'position' => 'bottom',
221 'title' => __( 'We use cookies', 'ablocks' ),
222 'message' => __( 'We use cookies to run this site, to understand how it is used, and — with your permission — to personalise what you see. You can change your mind at any time.', 'ablocks' ),
223 'policy_url' => '',
224 'policy_label' => __( 'Privacy policy', 'ablocks' ),
225 'accept_label' => __( 'Accept all', 'ablocks' ),
226 'reject_label' => __( 'Reject all', 'ablocks' ),
227 'settings_label' => __( 'Preferences', 'ablocks' ),
228 'save_label' => __( 'Save choices', 'ablocks' ),
229 'prefs_title' => __( 'Cookie preferences', 'ablocks' ),
230 'prefs_intro' => __( 'Choose which categories you allow. Strictly necessary cookies are always on.', 'ablocks' ),
231 'show_reject' => true,
232 'show_settings' => true,
233
234 /*
235 * A way out of the banner that is not "yes".
236 *
237 * Off by default, because in an opt-in regime the honest default is
238 * that the question stays until it is answered. But a site that has
239 * turned Reject off — a legitimate choice, and a common one — leaves
240 * a visitor who will not accept with nothing to do but leave, and
241 * the banner in front of them on every page forever. That is worse
242 * for everyone than a close button.
243 *
244 * Closing is never consent. `dismiss` stores nothing and releases
245 * nothing: it only stops asking for a while. `reject` writes a
246 * refusal, which is the same outcome made durable.
247 */
248 'show_banner_close' => false,
249 'close_behaviour' => 'dismiss',
250 // 0 keeps the marker until the browser closes — the visitor has not
251 // answered, so they should be asked again on their next visit, just
252 // not on every page of this one.
253 'dismiss_days' => 0,
254 // How much weight the Preferences button carries. A link is the
255 // quietest of the three and stays the default; an outline gives it
256 // the same presence as Accept without the same pull, which is what
257 // most consent managers settle on.
258 'settings_style' => 'link',
259 'overlay' => false,
260 'delay' => 0,
261 'reopen' => true,
262 'reopen_label' => __( 'Cookie preferences', 'ablocks' ),
263 'reopen_position' => 'bottom-left',
264
265 /*
266 * The preferences panel.
267 *
268 * `inline` swaps the banner's own contents for the category list,
269 * which is what this addon has always done and what every existing
270 * install is configured for — so it stays the default. `modal`
271 * lifts the list into a centred dialog and leaves the banner where
272 * it is behind, which is the shape most commercial consent managers
273 * use and the only way to have a small corner notice open into
274 * something big enough to read.
275 */
276 'prefs_layout' => 'inline',
277 'prefs_max_width' => 560,
278 'prefs_accordion' => false,
279 'prefs_open_first' => false,
280 'show_cookie_table' => false,
281 // Closes the preferences and goes back to the banner. It never
282 // dismisses the banner itself and never records a decision:
283 // closing a dialog is not an answer to the question.
284 'show_close' => true,
285 'show_prefs_reject' => true,
286 'show_prefs_accept' => false,
287 'prefs_accept_label' => __( 'Accept all', 'ablocks' ),
288 'cookie_policy_url' => '',
289 'cookie_policy_label' => __( 'Cookie policy', 'ablocks' ),
290 'locked_label' => __( 'Always on', 'ablocks' ),
291 'locked_style' => 'text',
292 'switch_style' => 'switch',
293
294 'bg' => '#ffffff',
295 'text' => '#1e1e1e',
296 'muted' => '#5c5f66',
297 'border' => '#e2e4e9',
298 'accent' => '#5033ec',
299 'accent_text' => '#ffffff',
300 'secondary_bg' => '#f2f2f5',
301 'secondary_text' => '#1e1e1e',
302 // Save has its own pair rather than borrowing the accent, so a
303 // panel can distinguish "save what I chose" from "accept
304 // everything" when both are on screen. Shipped equal to the accent
305 // so nothing moves for an install that never touches them.
306 'save_bg' => '#5033ec',
307 'save_text' => '#ffffff',
308 'locked_bg' => '#e7f6ec',
309 'locked_text' => '#1c7a41',
310 'radius' => 10,
311 'max_width' => 1180,
312 'shadow' => true,
313 ];
314 }
315
316 /**
317 * Everything, before any saved value is layered on.
318 */
319 public static function defaults() {
320 return [
321 // `optin` blocks first and asks; `notice` shows the banner and gates
322 // nothing, for sites that only need to tell visitors what they use.
323 'mode' => 'optin',
324 'enabled' => true,
325 'policy_version' => 1,
326 // 'default' keeps the shipped name and hides the field; only
327 // 'custom' lets one be typed, because a rename is a migration and
328 // not something to fall into by clicking in a text box.
329 'cookie_name_mode' => 'default',
330 'cookie_name' => 'ablocks_consent',
331 // Set automatically when the name changes, so a rename does not
332 // orphan every decision already made. Holds one hop only.
333 'cookie_name_previous' => '',
334 'cookie_days' => 365,
335 // Tags aBlocks prints itself, already gated. Empty means the site
336 // adds its own tags elsewhere and the rules below catch them.
337 'tag_gtm' => '',
338 'tag_ga4' => '',
339 'tag_meta_pixel' => '',
340 'reconsent_days' => 0,
341 // The output-buffer layer. On by default because without it the
342 // feature covers only enqueued scripts, which is most of what a
343 // site owner would consider "not covered".
344 'buffer_gating' => true,
345 // The iframe and pixel layer. Separate from `buffer_gating`
346 // because it is the one that can change what the visitor sees: a
347 // site that wants scripts held back but its videos left alone can
348 // say so without giving up the rest.
349 'embed_gating' => true,
350 'embed_rules' => [],
351 'pixel_rules' => [],
352 // Report what would be gated, gate nothing. The way to find out
353 // what breaks before it breaks.
354 'dry_run' => false,
355 'consent_mode' => true,
356 'consent_mode_wait' => 500,
357 // `ads_data_redaction` and `url_passthrough`. On by default: they
358 // only change what happens once something has been refused, and
359 // what they change is the cost of the refusal.
360 'consent_mode_ads' => true,
361 'record_enabled' => true,
362 'record_ip' => false,
363 'record_retention_days' => 730,
364 'hide_for_admins' => false,
365 'categories' => self::default_categories(),
366 'rules' => self::default_rules(),
367 'banner' => self::default_banner(),
368 ];
369 }
370
371 /**
372 * The current settings, defaults merged under whatever is saved.
373 *
374 * Merging one level deep on `banner` matters: a settings blob saved by an
375 * older version is missing keys a newer one reads, and a missing colour is
376 * an unreadable banner rather than a notice.
377 */
378 /**
379 * Drop the per-request cache.
380 *
381 * The settings are read many times per page and change once, so they are
382 * memoised. Anything that writes the option in the same request — the save
383 * handler, and the test suite stepping through configurations — has to say
384 * so, or it keeps reading what was there before.
385 */
386 public static function flush() {
387 self::$settings = null;
388 }
389
390 public static function get_settings() {
391 if ( null !== self::$settings ) {
392 return self::$settings;
393 }
394
395 $defaults = self::defaults();
396
397 // The option holds a JSON string, but nothing stops another process
398 // writing an array into it — a migration, a staging sync, `wp option
399 // update --format=json`. json_decode() is typed against a string in
400 // PHP 8, so without this guard that mistake is a fatal on every page
401 // of the site rather than a setting that reads oddly.
402 $stored = get_option( ABLOCKS_COOKIE_CONSENT_SETTINGS_NAME, '{}' );
403 $saved = is_string( $stored ) ? json_decode( $stored, true ) : $stored;
404 $saved = is_array( $saved ) ? $saved : [];
405
406 $settings = array_merge( $defaults, $saved );
407 $settings['banner'] = array_merge( $defaults['banner'], isset( $saved['banner'] ) && is_array( $saved['banner'] ) ? $saved['banner'] : [] );
408
409 if ( empty( $settings['categories'] ) || ! is_array( $settings['categories'] ) ) {
410 $settings['categories'] = $defaults['categories'];
411 }
412 if ( ! isset( $saved['rules'] ) || ! is_array( $saved['rules'] ) ) {
413 $settings['rules'] = $defaults['rules'];
414 }
415
416 self::$settings = apply_filters( 'ablocks/cookie_consent/settings', $settings );
417 return self::$settings;
418 }
419
420 /**
421 * One setting.
422 *
423 * @param string $key Setting key.
424 * @param mixed $default Fallback.
425 * @return mixed
426 */
427 public static function get( $key, $default = null ) {
428 $settings = self::get_settings();
429 return array_key_exists( $key, $settings ) ? $settings[ $key ] : $default;
430 }
431
432 /**
433 * One banner setting.
434 *
435 * @param string $key Banner key.
436 * @param mixed $default Fallback.
437 * @return mixed
438 */
439 public static function banner( $key, $default = null ) {
440 $banner = self::get( 'banner', [] );
441 return isset( $banner[ $key ] ) ? $banner[ $key ] : $default;
442 }
443
444 public static function save_settings( array $settings ) {
445 self::flush();
446 return update_option( ABLOCKS_COOKIE_CONSENT_SETTINGS_NAME, wp_json_encode( $settings ) );
447 }
448
449 /**
450 * The categories that are switched on, `necessary` always first.
451 */
452 public static function active_categories() {
453 $categories = array_values(
454 array_filter(
455 (array) self::get( 'categories', [] ),
456 function ( $category ) {
457 return ! empty( $category['slug'] ) && ! empty( $category['enabled'] );
458 }
459 )
460 );
461
462 usort(
463 $categories,
464 function ( $a, $b ) {
465 if ( ! empty( $a['locked'] ) === ! empty( $b['locked'] ) ) {
466 return 0;
467 }
468 return ! empty( $a['locked'] ) ? -1 : 1;
469 }
470 );
471
472 return apply_filters( 'ablocks/cookie_consent/categories', $categories );
473 }
474
475 /**
476 * Whether a category exists and is switched on.
477 *
478 * A rule pointing at a category the site has deleted would gate against a
479 * choice the visitor is never offered — for a script that means it never
480 * runs, and for an embed it means a card that can never be dismissed.
481 *
482 * @param string $slug Category slug.
483 * @return bool
484 */
485 public static function category_is_active( $slug ) {
486 foreach ( self::active_categories() as $category ) {
487 if ( isset( $category['slug'] ) && $category['slug'] === $slug ) {
488 return true;
489 }
490 }
491 return false;
492 }
493
494 /**
495 * Category slugs that a visitor can actually refuse.
496 */
497 public static function refusable_slugs() {
498 $slugs = [];
499 foreach ( self::active_categories() as $category ) {
500 if ( empty( $category['locked'] ) ) {
501 $slugs[] = $category['slug'];
502 }
503 }
504 return $slugs;
505 }
506
507 /**
508 * The enabled rules, keyed by nothing in particular — order is match order.
509 */
510 public static function active_rules() {
511 $refusable = self::refusable_slugs();
512 $rules = array_values(
513 array_filter(
514 (array) self::get( 'rules', [] ),
515 function ( $rule ) use ( $refusable ) {
516 // A rule pointing at a category that is off, or at
517 // `necessary`, would gate a script that is never released.
518 return ! empty( $rule['enabled'] )
519 && ! empty( $rule['category'] )
520 && in_array( $rule['category'], $refusable, true );
521 }
522 )
523 );
524 return apply_filters( 'ablocks/cookie_consent/rules', $rules );
525 }
526
527 /**
528 * Whether gating should run at all for this request.
529 *
530 * Dry run counts as "running": it walks the same rules, it just reports
531 * instead of rewriting.
532 *
533 * This has to answer false wherever `should_render_banner()` does, because
534 * the two halves are one mechanism: gating holds a tag back, and the banner
535 * is the only thing that ever lets it go. Gate without a banner and the tag
536 * is frozen for the rest of that visitor's session, with nothing on the
537 * page able to release it.
538 */
539 public static function is_gating_active() {
540 if ( ! self::get( 'enabled', true ) || 'optin' !== self::get( 'mode', 'optin' ) ) {
541 return false;
542 }
543 // The earliest caller registers on `wp`, so the current user is
544 // resolved by the time this runs and the capability check is safe here.
545 if ( self::get( 'hide_for_admins', false ) && current_user_can( 'manage_options' ) ) {
546 return false;
547 }
548 if ( is_admin() || wp_doing_ajax() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
549 return false;
550 }
551 // Conditional query tags always answer false before the query has run,
552 // so asking early would quietly gate a feed rather than skip it.
553 if ( did_action( 'wp' ) && is_feed() ) {
554 return false;
555 }
556 return (bool) apply_filters( 'ablocks/cookie_consent/is_gating_active', true );
557 }
558
559 /**
560 * Whether the banner should be printed for this request.
561 */
562 public static function should_render_banner() {
563 if ( ! self::get( 'enabled', true ) ) {
564 return false;
565 }
566 if ( self::get( 'hide_for_admins', false ) && current_user_can( 'manage_options' ) ) {
567 return false;
568 }
569 if ( is_admin() ) {
570 return false;
571 }
572 if ( did_action( 'wp' ) && ( is_feed() || is_embed() ) ) {
573 return false;
574 }
575 return (bool) apply_filters( 'ablocks/cookie_consent/should_render_banner', true );
576 }
577
578 /**
579 * Which parts of this addon are Pro.
580 *
581 * The line is drawn where a feature is a power-user or agency need rather
582 * than part of asking the question correctly. Two things are deliberately
583 * NOT here:
584 *
585 * - **Google Consent Mode v2.** Gating it would mean a free user's Google
586 * Ads quietly under-delivers on EEA traffic. Charging for the thing that
587 * stops a third party punishing you is the wrong shape of paywall.
588 * - **The scan report.** It is what tells a site owner their configuration
589 * is incomplete. Hiding it makes the free version silently wrong, which
590 * is worse than making it smaller.
591 *
592 * Everything about the banner — every layout, every colour — stays free.
593 * This is a design plugin; gating the design would be off-brand.
594 *
595 * @return array Feature slug => whether this install has it.
596 */
597 public static function pro_features() {
598 $has_pro = \ABlocks\Helper::is_active_ablocks_pro();
599
600 return apply_filters(
601 'ablocks/cookie_consent/pro_features',
602 [
603 // Writing your own matcher, rather than switching the shipped
604 // ones on and off.
605 'custom_rules' => $has_pro,
606 // The server-side audit log. Free keeps the visitor's own
607 // cookie, which is the decision; Pro keeps the evidence.
608 'records' => $has_pro,
609 ],
610 $has_pro
611 );
612 }
613
614 /**
615 * Whether this install has a given Pro feature.
616 *
617 * Checked on the server as well as in the UI. A disabled control is a
618 * courtesy, not a boundary.
619 *
620 * @param string $feature Feature slug.
621 * @return bool
622 */
623 public static function can( $feature ) {
624 $features = self::pro_features();
625 return ! empty( $features[ $feature ] );
626 }
627
628 /**
629 * The ids of the rules that ship with the plugin.
630 *
631 * Without Pro these can be switched on and off and pointed at a different
632 * category — that is configuration — but their patterns are not editable
633 * and no new rule can be added.
634 *
635 * @return array
636 */
637 public static function shipped_rule_ids() {
638 return wp_list_pluck( self::default_rules(), 'id' );
639 }
640
641 /**
642 * A short hash of the banner's wording, stored with each consent record.
643 *
644 * Without it a record says "they agreed" but not what to; with it the text
645 * in force at the time can be identified even after the wording changes.
646 */
647 public static function banner_hash() {
648 $banner = self::get( 'banner', [] );
649 $parts = [
650 isset( $banner['title'] ) ? $banner['title'] : '',
651 isset( $banner['message'] ) ? $banner['message'] : '',
652 isset( $banner['prefs_intro'] ) ? $banner['prefs_intro'] : '',
653 ];
654 foreach ( self::active_categories() as $category ) {
655 $parts[] = $category['slug'] . ':' . ( isset( $category['description'] ) ? $category['description'] : '' );
656 }
657 return substr( md5( implode( '|', $parts ) ), 0, 32 );
658 }
659 }
660