PluginProbe
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder / 2.12.0
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder v2.12.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 1.1.2 All 80 releases
ablocks / addons / cookie-consent / helper.php

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

619 lines 20.9 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 // Report what would be gated, gate nothing. The way to find out
346 // what breaks before it breaks.
347 'dry_run' => false,
348 'consent_mode' => true,
349 'consent_mode_wait' => 500,
350 'record_enabled' => true,
351 'record_ip' => false,
352 'record_retention_days' => 730,
353 'hide_for_admins' => false,
354 'categories' => self::default_categories(),
355 'rules' => self::default_rules(),
356 'banner' => self::default_banner(),
357 ];
358 }
359
360 /**
361 * The current settings, defaults merged under whatever is saved.
362 *
363 * Merging one level deep on `banner` matters: a settings blob saved by an
364 * older version is missing keys a newer one reads, and a missing colour is
365 * an unreadable banner rather than a notice.
366 */
367 /**
368 * Drop the per-request cache.
369 *
370 * The settings are read many times per page and change once, so they are
371 * memoised. Anything that writes the option in the same request — the save
372 * handler, and the test suite stepping through configurations — has to say
373 * so, or it keeps reading what was there before.
374 */
375 public static function flush() {
376 self::$settings = null;
377 }
378
379 public static function get_settings() {
380 if ( null !== self::$settings ) {
381 return self::$settings;
382 }
383
384 $defaults = self::defaults();
385
386 // The option holds a JSON string, but nothing stops another process
387 // writing an array into it — a migration, a staging sync, `wp option
388 // update --format=json`. json_decode() is typed against a string in
389 // PHP 8, so without this guard that mistake is a fatal on every page
390 // of the site rather than a setting that reads oddly.
391 $stored = get_option( ABLOCKS_COOKIE_CONSENT_SETTINGS_NAME, '{}' );
392 $saved = is_string( $stored ) ? json_decode( $stored, true ) : $stored;
393 $saved = is_array( $saved ) ? $saved : [];
394
395 $settings = array_merge( $defaults, $saved );
396 $settings['banner'] = array_merge( $defaults['banner'], isset( $saved['banner'] ) && is_array( $saved['banner'] ) ? $saved['banner'] : [] );
397
398 if ( empty( $settings['categories'] ) || ! is_array( $settings['categories'] ) ) {
399 $settings['categories'] = $defaults['categories'];
400 }
401 if ( ! isset( $saved['rules'] ) || ! is_array( $saved['rules'] ) ) {
402 $settings['rules'] = $defaults['rules'];
403 }
404
405 self::$settings = apply_filters( 'ablocks/cookie_consent/settings', $settings );
406 return self::$settings;
407 }
408
409 /**
410 * One setting.
411 *
412 * @param string $key Setting key.
413 * @param mixed $default Fallback.
414 * @return mixed
415 */
416 public static function get( $key, $default = null ) {
417 $settings = self::get_settings();
418 return array_key_exists( $key, $settings ) ? $settings[ $key ] : $default;
419 }
420
421 /**
422 * One banner setting.
423 *
424 * @param string $key Banner key.
425 * @param mixed $default Fallback.
426 * @return mixed
427 */
428 public static function banner( $key, $default = null ) {
429 $banner = self::get( 'banner', [] );
430 return isset( $banner[ $key ] ) ? $banner[ $key ] : $default;
431 }
432
433 public static function save_settings( array $settings ) {
434 self::flush();
435 return update_option( ABLOCKS_COOKIE_CONSENT_SETTINGS_NAME, wp_json_encode( $settings ) );
436 }
437
438 /**
439 * The categories that are switched on, `necessary` always first.
440 */
441 public static function active_categories() {
442 $categories = array_values(
443 array_filter(
444 (array) self::get( 'categories', [] ),
445 function ( $category ) {
446 return ! empty( $category['slug'] ) && ! empty( $category['enabled'] );
447 }
448 )
449 );
450
451 usort(
452 $categories,
453 function ( $a, $b ) {
454 if ( ! empty( $a['locked'] ) === ! empty( $b['locked'] ) ) {
455 return 0;
456 }
457 return ! empty( $a['locked'] ) ? -1 : 1;
458 }
459 );
460
461 return apply_filters( 'ablocks/cookie_consent/categories', $categories );
462 }
463
464 /**
465 * Category slugs that a visitor can actually refuse.
466 */
467 public static function refusable_slugs() {
468 $slugs = [];
469 foreach ( self::active_categories() as $category ) {
470 if ( empty( $category['locked'] ) ) {
471 $slugs[] = $category['slug'];
472 }
473 }
474 return $slugs;
475 }
476
477 /**
478 * The enabled rules, keyed by nothing in particular — order is match order.
479 */
480 public static function active_rules() {
481 $refusable = self::refusable_slugs();
482 $rules = array_values(
483 array_filter(
484 (array) self::get( 'rules', [] ),
485 function ( $rule ) use ( $refusable ) {
486 // A rule pointing at a category that is off, or at
487 // `necessary`, would gate a script that is never released.
488 return ! empty( $rule['enabled'] )
489 && ! empty( $rule['category'] )
490 && in_array( $rule['category'], $refusable, true );
491 }
492 )
493 );
494 return apply_filters( 'ablocks/cookie_consent/rules', $rules );
495 }
496
497 /**
498 * Whether gating should run at all for this request.
499 *
500 * Dry run counts as "running": it walks the same rules, it just reports
501 * instead of rewriting.
502 */
503 public static function is_gating_active() {
504 if ( ! self::get( 'enabled', true ) || 'optin' !== self::get( 'mode', 'optin' ) ) {
505 return false;
506 }
507 if ( is_admin() || wp_doing_ajax() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
508 return false;
509 }
510 // Conditional query tags always answer false before the query has run,
511 // so asking early would quietly gate a feed rather than skip it.
512 if ( did_action( 'wp' ) && is_feed() ) {
513 return false;
514 }
515 return (bool) apply_filters( 'ablocks/cookie_consent/is_gating_active', true );
516 }
517
518 /**
519 * Whether the banner should be printed for this request.
520 */
521 public static function should_render_banner() {
522 if ( ! self::get( 'enabled', true ) ) {
523 return false;
524 }
525 if ( self::get( 'hide_for_admins', false ) && current_user_can( 'manage_options' ) ) {
526 return false;
527 }
528 if ( is_admin() ) {
529 return false;
530 }
531 if ( did_action( 'wp' ) && ( is_feed() || is_embed() ) ) {
532 return false;
533 }
534 return (bool) apply_filters( 'ablocks/cookie_consent/should_render_banner', true );
535 }
536
537 /**
538 * Which parts of this addon are Pro.
539 *
540 * The line is drawn where a feature is a power-user or agency need rather
541 * than part of asking the question correctly. Two things are deliberately
542 * NOT here:
543 *
544 * - **Google Consent Mode v2.** Gating it would mean a free user's Google
545 * Ads quietly under-delivers on EEA traffic. Charging for the thing that
546 * stops a third party punishing you is the wrong shape of paywall.
547 * - **The scan report.** It is what tells a site owner their configuration
548 * is incomplete. Hiding it makes the free version silently wrong, which
549 * is worse than making it smaller.
550 *
551 * Everything about the banner — every layout, every colour — stays free.
552 * This is a design plugin; gating the design would be off-brand.
553 *
554 * @return array Feature slug => whether this install has it.
555 */
556 public static function pro_features() {
557 $has_pro = \ABlocks\Helper::is_active_ablocks_pro();
558
559 return apply_filters(
560 'ablocks/cookie_consent/pro_features',
561 [
562 // Writing your own matcher, rather than switching the shipped
563 // ones on and off.
564 'custom_rules' => $has_pro,
565 // The server-side audit log. Free keeps the visitor's own
566 // cookie, which is the decision; Pro keeps the evidence.
567 'records' => $has_pro,
568 ],
569 $has_pro
570 );
571 }
572
573 /**
574 * Whether this install has a given Pro feature.
575 *
576 * Checked on the server as well as in the UI. A disabled control is a
577 * courtesy, not a boundary.
578 *
579 * @param string $feature Feature slug.
580 * @return bool
581 */
582 public static function can( $feature ) {
583 $features = self::pro_features();
584 return ! empty( $features[ $feature ] );
585 }
586
587 /**
588 * The ids of the rules that ship with the plugin.
589 *
590 * Without Pro these can be switched on and off and pointed at a different
591 * category — that is configuration — but their patterns are not editable
592 * and no new rule can be added.
593 *
594 * @return array
595 */
596 public static function shipped_rule_ids() {
597 return wp_list_pluck( self::default_rules(), 'id' );
598 }
599
600 /**
601 * A short hash of the banner's wording, stored with each consent record.
602 *
603 * Without it a record says "they agreed" but not what to; with it the text
604 * in force at the time can be identified even after the wording changes.
605 */
606 public static function banner_hash() {
607 $banner = self::get( 'banner', [] );
608 $parts = [
609 isset( $banner['title'] ) ? $banner['title'] : '',
610 isset( $banner['message'] ) ? $banner['message'] : '',
611 isset( $banner['prefs_intro'] ) ? $banner['prefs_intro'] : '',
612 ];
613 foreach ( self::active_categories() as $category ) {
614 $parts[] = $category['slug'] . ':' . ( isset( $category['description'] ) ? $category['description'] : '' );
615 }
616 return substr( md5( implode( '|', $parts ) ), 0, 32 );
617 }
618 }
619