PluginProbe
King Addons for Elementor – 100+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce Builder, Mega Menu, Popup Builder / 51.1.84
King Addons for Elementor – 100+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce Builder, Mega Menu, Popup Builder v51.1.84
51.1.86 51.1.84 51.1.85 51.1.83 51.1.82 51.1.81 51.1.79 51.1.78 51.1.77 51.1.76 51.1.74 51.1.75 51.1.65 51.1.64 51.1.63 trunk 51.1.14 51.1.2 51.1.35 51.1.36 51.1.37 51.1.38 51.1.39 51.1.44 51.1.45 All 40 releases
king-addons / includes / extensions / Cookie_Consent / Cookie_Consent.php

Cookie_Consent.php in King Addons for Elementor – 100+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce Builder, Mega Menu, Popup Builder 51.1.84, at includes/extensions/Cookie_Consent/Cookie_Consent.php

1,185 lines 41.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Cookie / Consent Bar extension.
4 *
5 * @package King_Addons
6 */
7
8 namespace King_Addons;
9
10 use WP_Error;
11
12 if (!defined('ABSPATH')) {
13 exit;
14 }
15
16 /**
17 * Handles admin UI, frontend output, consent storage and script blocking.
18 */
19 final class Cookie_Consent
20 {
21 private const OPTION_NAME = 'king_addons_cookie_consent_options';
22 private const CONSENT_COOKIE = 'ka_cookie_consent';
23 public const LOG_TABLE = 'king_addons_cookie_consent_log';
24 private const GEO_TRANSIENT = 'king_addons_cookie_geo';
25 private const GEO_TTL = DAY_IN_SECONDS;
26 private const FREE_SCRIPT_LIMIT = 3;
27
28 private static ?Cookie_Consent $instance = null;
29
30 /**
31 * Cached options array.
32 *
33 * @var array<string, mixed>
34 */
35 private array $options = [];
36
37 /**
38 * Bootstraps the extension.
39 *
40 * @return Cookie_Consent
41 */
42 public static function instance(): Cookie_Consent
43 {
44 if (is_null(self::$instance)) {
45 self::$instance = new self();
46 }
47
48 return self::$instance;
49 }
50
51 /**
52 * Constructor. Loads options and registers hooks.
53 */
54 public function __construct()
55 {
56 $this->options = $this->get_options();
57
58 register_activation_hook(KING_ADDONS_PATH . 'king-addons.php', [$this, 'handle_activation']);
59
60 add_action('admin_post_king_addons_cookie_consent_save', [$this, 'handle_save_settings']);
61 add_action('admin_post_king_addons_cookie_consent_export', [$this, 'handle_export_settings']);
62 add_action('admin_post_king_addons_cookie_consent_import', [$this, 'handle_import_settings']);
63 add_action('admin_post_king_addons_cookie_consent_clear_logs', [$this, 'handle_clear_logs']);
64
65 add_action('admin_enqueue_scripts', [$this, 'enqueue_admin_assets']);
66
67 add_action('wp_enqueue_scripts', [$this, 'enqueue_front_assets']);
68 add_action('wp_footer', [$this, 'render_frontend_markup']);
69 add_action('wp_body_open', [$this, 'render_portal_container']);
70 add_filter('script_loader_tag', [$this, 'filter_script_loader_tag'], 10, 3);
71
72 add_action('wp_ajax_king_addons_cookie_consent_log', [$this, 'ajax_log_consent']);
73 add_action('wp_ajax_nopriv_king_addons_cookie_consent_log', [$this, 'ajax_log_consent']);
74
75 add_shortcode('king_addons_cookie_settings', [$this, 'render_manage_button_shortcode']);
76 }
77
78 /**
79 * Creates defaults and database table on activation.
80 *
81 * @return void
82 */
83 public function handle_activation(): void
84 {
85 if (!get_option(self::OPTION_NAME)) {
86 add_option(self::OPTION_NAME, $this->get_default_options());
87 }
88
89 if ($this->is_premium()) {
90 $this->maybe_create_log_table();
91 }
92 }
93
94 /**
95 * Renders the admin settings page.
96 *
97 * @return void
98 */
99 public function render_admin_page(): void
100 {
101 if (!current_user_can('manage_options')) {
102 return;
103 }
104
105 $this->options = $this->get_options();
106 $is_premium = $this->is_premium();
107 $options = $this->options;
108
109 include __DIR__ . '/templates/admin-page.php';
110 }
111
112 /**
113 * Enqueues admin assets for the settings page.
114 *
115 * @param string $hook Current admin hook.
116 * @return void
117 */
118 public function enqueue_admin_assets(string $hook): void
119 {
120 if ($hook !== 'king-addons_page_king-addons-cookie-consent') {
121 return;
122 }
123
124 wp_enqueue_style('wp-color-picker');
125 wp_enqueue_script('wp-color-picker');
126
127 wp_enqueue_style(
128 'king-addons-cookie-consent-admin',
129 KING_ADDONS_URL . 'includes/extensions/Cookie_Consent/assets/admin.css',
130 [],
131 KING_ADDONS_VERSION
132 );
133
134 wp_enqueue_script(
135 'king-addons-cookie-consent-admin',
136 KING_ADDONS_URL . 'includes/extensions/Cookie_Consent/assets/admin.js',
137 ['jquery', 'wp-color-picker'],
138 KING_ADDONS_VERSION,
139 true
140 );
141
142 wp_localize_script('king-addons-cookie-consent-admin', 'kingAddonsCookieAdmin', [
143 'saveText' => __('Save Settings', 'king-addons'),
144 ]);
145 }
146
147 /**
148 * Handles settings save.
149 *
150 * @return void
151 */
152 public function handle_save_settings(): void
153 {
154 if (!current_user_can('manage_options')) {
155 wp_die(esc_html__('Access denied', 'king-addons'));
156 }
157
158 check_admin_referer('king_addons_cookie_consent_save');
159
160 $sanitized = $this->sanitize_options($_POST);
161
162 update_option(self::OPTION_NAME, $sanitized);
163 $this->options = $sanitized;
164
165 wp_safe_redirect(
166 add_query_arg(
167 ['page' => 'king-addons-cookie-consent', 'updated' => 'true'],
168 admin_url('admin.php')
169 )
170 );
171 exit;
172 }
173
174 /**
175 * Exports settings as JSON.
176 *
177 * @return void
178 */
179 public function handle_export_settings(): void
180 {
181 if (!current_user_can('manage_options')) {
182 wp_die(esc_html__('Access denied', 'king-addons'));
183 }
184
185 if (!$this->is_premium()) {
186 wp_die(esc_html__('Upgrade to export settings.', 'king-addons'));
187 }
188
189 check_admin_referer('king_addons_cookie_consent_export');
190
191 $options = $this->options;
192 $json = wp_json_encode($options, JSON_PRETTY_PRINT);
193
194 nocache_headers();
195 header('Content-Type: application/json; charset=utf-8');
196 header('Content-Disposition: attachment; filename=cookie-consent-settings.json');
197 echo $json; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
198 exit;
199 }
200
201 /**
202 * Imports settings from JSON upload.
203 *
204 * @return void
205 */
206 public function handle_import_settings(): void
207 {
208 if (!current_user_can('manage_options')) {
209 wp_die(esc_html__('Access denied', 'king-addons'));
210 }
211
212 if (!$this->is_premium()) {
213 wp_die(esc_html__('Upgrade to import settings.', 'king-addons'));
214 }
215
216 check_admin_referer('king_addons_cookie_consent_import');
217
218 if (!isset($_FILES['king_addons_cookie_import']) || !is_uploaded_file($_FILES['king_addons_cookie_import']['tmp_name'])) {
219 wp_die(esc_html__('No file uploaded.', 'king-addons'));
220 }
221
222 $raw = file_get_contents($_FILES['king_addons_cookie_import']['tmp_name']);
223 $decoded = json_decode($raw, true);
224
225 if (!is_array($decoded)) {
226 wp_die(esc_html__('Invalid JSON file.', 'king-addons'));
227 }
228
229 $sanitized = $this->sanitize_options($decoded);
230 update_option(self::OPTION_NAME, $sanitized);
231 $this->options = $sanitized;
232
233 wp_safe_redirect(
234 add_query_arg(
235 ['page' => 'king-addons-cookie-consent', 'imported' => 'true'],
236 admin_url('admin.php')
237 )
238 );
239 exit;
240 }
241
242 /**
243 * Clears logs older than retention.
244 *
245 * @return void
246 */
247 public function handle_clear_logs(): void
248 {
249 if (!current_user_can('manage_options')) {
250 wp_die(esc_html__('Access denied', 'king-addons'));
251 }
252
253 if (!$this->is_premium()) {
254 wp_die(esc_html__('Upgrade to manage logs.', 'king-addons'));
255 }
256
257 check_admin_referer('king_addons_cookie_consent_clear_logs');
258
259 global $wpdb;
260 $table = $wpdb->prefix . self::LOG_TABLE;
261 $retention = isset($_POST['retention']) ? sanitize_text_field(wp_unslash($_POST['retention'])) : 'all';
262
263 if ($retention === 'all') {
264 $wpdb->query("TRUNCATE TABLE {$table}");
265 } else {
266 $days = absint($retention);
267 $wpdb->query(
268 $wpdb->prepare(
269 "DELETE FROM {$table} WHERE logged_at < (NOW() - INTERVAL %d DAY)",
270 $days
271 )
272 );
273 }
274
275 wp_safe_redirect(
276 add_query_arg(
277 ['page' => 'king-addons-cookie-consent', 'logs_cleared' => 'true'],
278 admin_url('admin.php')
279 )
280 );
281 exit;
282 }
283
284 /**
285 * Enqueues frontend assets and localizes settings.
286 *
287 * @return void
288 */
289 public function enqueue_front_assets(): void
290 {
291 if (is_admin()) {
292 return;
293 }
294
295 if (!$this->should_render()) {
296 return;
297 }
298
299 wp_register_style(
300 'king-addons-cookie-consent',
301 KING_ADDONS_URL . 'includes/extensions/Cookie_Consent/assets/style.css',
302 [],
303 (string) filemtime(KING_ADDONS_PATH . 'includes/extensions/Cookie_Consent/assets/style.css')
304 );
305 wp_register_script(
306 'king-addons-cookie-consent',
307 KING_ADDONS_URL . 'includes/extensions/Cookie_Consent/assets/script.js',
308 [],
309 KING_ADDONS_VERSION,
310 true
311 );
312
313 $payload = $this->get_frontend_payload();
314 wp_localize_script('king-addons-cookie-consent', 'kingAddonsCookieConsent', $payload);
315
316 wp_enqueue_style('king-addons-cookie-consent');
317 wp_enqueue_script('king-addons-cookie-consent');
318 }
319
320 /**
321 * Outputs the frontend container when enabled and targeted.
322 *
323 * @return void
324 */
325 public function render_frontend_markup(): void
326 {
327 if (!$this->should_render()) {
328 return;
329 }
330
331 ?>
332 <div class="king-addons-cookie-consent" id="king-addons-cookie-consent" data-cookie-name="<?php echo esc_attr($this->options['advanced']['cookie_name']); ?>">
333 <div class="king-addons-cookie-consent__banner" aria-live="polite"></div>
334 <div class="king-addons-cookie-consent__modal" role="dialog" aria-modal="true"></div>
335 </div>
336 <?php
337 }
338
339 /**
340 * Adds a portal container early in the markup for overlays.
341 *
342 * @return void
343 */
344 public function render_portal_container(): void
345 {
346 if (!$this->should_render()) {
347 return;
348 }
349
350 echo '<div id="king-addons-cookie-portal" class="king-addons-cookie-consent__portal" aria-hidden="true"></div>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
351 }
352
353 /**
354 * Filters script tags and blocks them until consent is granted.
355 *
356 * @param string $tag Original script tag.
357 * @param string $handle Script handle.
358 * @param string $src Script source.
359 * @return string
360 */
361 public function filter_script_loader_tag(string $tag, string $handle, string $src): string
362 {
363 if (is_admin()) {
364 return $tag;
365 }
366
367 $rules = $this->get_script_rules();
368 if (!isset($rules[$handle])) {
369 return $tag;
370 }
371
372 $category = $rules[$handle]['category'];
373 $mode = isset($rules[$handle]['mode']) ? (string) $rules[$handle]['mode'] : 'block';
374 if ($this->has_consent_for_category($category)) {
375 return $tag;
376 }
377
378 // Preserve original type (e.g. module) so JS can restore it after consent.
379 $original_type = '';
380 if (preg_match('/\s+type=(?:"([^"]*)"|\'([^\']*)\'|([^\s>]+))/i', $tag, $m)) {
381 $original_type = (string) ($m[1] ?? ($m[2] ?? ($m[3] ?? '')));
382 }
383
384 // If the rule is "allow", avoid downloading before consent by stripping src
385 // and storing it for later activation.
386 $src_attr = '';
387 if ($mode === 'allow') {
388 $tag = preg_replace('/\s+src=(?:"[^"]*"|\'[^\']*\'|[^\s>]+)/i', '', $tag);
389 $tag = preg_replace('/\s+data-ka-cookie-src=(?:"[^"]*"|\'[^\']*\'|[^\s>]+)/i', '', $tag);
390 if ($src !== '') {
391 $src_attr = ' data-ka-cookie-src="' . esc_url($src) . '"';
392 }
393 }
394
395 // Normalize: remove any existing type and category marker then force text/plain with category marker.
396 // This avoids invalid markup like duplicate type attributes (e.g. when original tag is type="module").
397 $tag = preg_replace('/\s+type=(?:"[^"]*"|\'[^\']*\'|[^\s>]+)/i', '', $tag);
398 $tag = preg_replace('/\s+data-ka-cookie-category=(?:"[^"]*"|\'[^\']*\'|[^\s>]+)/i', '', $tag);
399
400 // Avoid duplicates if another filter already added our attribute.
401 $tag = preg_replace('/\s+data-ka-cookie-original-type=(?:"[^"]*"|\'[^\']*\'|[^\s>]+)/i', '', $tag);
402
403 $original_type_attr = '';
404 if ($original_type !== '') {
405 $original_type_attr = ' data-ka-cookie-original-type="' . esc_attr($original_type) . '"';
406 }
407 $blocked_tag = preg_replace(
408 '/<script\b/i',
409 '<script type="text/plain" data-ka-cookie-category="' . esc_attr($category) . '"' . $original_type_attr . $src_attr,
410 $tag,
411 1
412 );
413
414 if (!is_string($blocked_tag) || $blocked_tag === '') {
415 return $tag;
416 }
417
418 return $blocked_tag;
419 }
420
421 /**
422 * Logs consent events via AJAX (Pro).
423 *
424 * @return void
425 */
426 public function ajax_log_consent(): void
427 {
428 if (!$this->is_premium()) {
429 wp_send_json_success(['message' => 'logging-disabled']);
430 }
431
432 check_ajax_referer('king_addons_cookie_log');
433
434 $action = isset($_POST['actionType']) ? sanitize_text_field(wp_unslash($_POST['actionType'])) : '';
435 $categories = isset($_POST['categories']) && is_array($_POST['categories']) ? array_map('sanitize_text_field', wp_unslash($_POST['categories'])) : [];
436 $region = isset($_POST['region']) ? sanitize_text_field(wp_unslash($_POST['region'])) : 'unknown';
437 $device = isset($_POST['device']) ? sanitize_text_field(wp_unslash($_POST['device'])) : 'unknown';
438
439 $this->maybe_create_log_table();
440 $this->insert_log_row($action, $categories, $region, $device);
441
442 wp_send_json_success(['message' => 'logged']);
443 }
444
445 /**
446 * Renders a shortcode manage button.
447 *
448 * @param array<string, mixed> $atts Shortcode attributes.
449 * @return string
450 */
451 public function render_manage_button_shortcode(array $atts = []): string
452 {
453 $atts = shortcode_atts(
454 [
455 'label' => __('Cookie settings', 'king-addons'),
456 'class' => '',
457 ],
458 $atts
459 );
460
461 $class = trim('king-addons-cookie-manage ' . sanitize_html_class($atts['class']));
462
463 return '<button type="button" class="' . esc_attr($class) . '" data-ka-cookie-manage="true">' . esc_html($atts['label']) . '</button>';
464 }
465
466 /**
467 * Retrieves options with defaults.
468 *
469 * @return array<string, mixed>
470 */
471 private function get_options(): array
472 {
473 if (class_exists('King_Addons\\Text_Entities_Migration')) {
474 Text_Entities_Migration::maybe_run();
475 }
476
477 $stored = get_option(self::OPTION_NAME, []);
478 $defaults = $this->get_default_options();
479
480 if (!is_array($stored)) {
481 $stored = [];
482 }
483
484 return array_replace_recursive($defaults, $stored);
485 }
486
487 /**
488 * Returns default option set.
489 *
490 * @return array<string, mixed>
491 */
492 private function get_default_options(): array
493 {
494 return [
495 'enabled' => false,
496 'mode' => 'gdpr',
497 'region_targeting' => 'all',
498 'custom_regions' => '',
499 'consent_lifetime' => '365',
500 'policy_version' => '1',
501 'template' => 'gdpr_minimal',
502 'content' => [
503 'title' => __('We value your privacy', 'king-addons'),
504 'message' => __('We use cookies to enhance your browsing experience, serve personalized ads or content, and analyze our traffic. By clicking "Accept all", you consent to our use of cookies.', 'king-addons'),
505 'privacy_label' => __('Privacy Policy', 'king-addons'),
506 'privacy_url' => '',
507 'cookie_label' => __('Cookie Policy', 'king-addons'),
508 'cookie_url' => '',
509 'cookie_url_custom' => '',
510 ],
511 'buttons' => [
512 'accept' => __('Accept all', 'king-addons'),
513 'reject' => __('Reject all', 'king-addons'),
514 'settings' => __('Cookie settings', 'king-addons'),
515 'save' => __('Save preferences', 'king-addons'),
516 ],
517 'categories' => [
518 [
519 'key' => 'necessary',
520 'label' => __('Strictly necessary', 'king-addons'),
521 'description' => __('Required for the site to function correctly. These cookies cannot be disabled.', 'king-addons'),
522 'state' => 'required',
523 'display' => true,
524 ],
525 [
526 'key' => 'analytics',
527 'label' => __('Analytics', 'king-addons'),
528 'description' => __('Helps us improve the site by collecting anonymous usage data about how you interact with it.', 'king-addons'),
529 'state' => 'off',
530 'display' => true,
531 ],
532 [
533 'key' => 'marketing',
534 'label' => __('Marketing', 'king-addons'),
535 'description' => __('Used to deliver personalized advertisements and measure their performance.', 'king-addons'),
536 'state' => 'off',
537 'display' => true,
538 ],
539 [
540 'key' => 'other',
541 'label' => __('Other', 'king-addons'),
542 'description' => __('Additional cookies that do not fit into other categories.', 'king-addons'),
543 'state' => 'off',
544 'display' => true,
545 ],
546 ],
547 'design' => [
548 'layout' => 'bottom-bar',
549 'preset' => 'light',
550 'position' => 'center',
551 'width' => 'full',
552 'animation' => 'fade',
553 'shadow' => true,
554 'border_radius' => 10,
555 'colors' => [
556 'background' => '#111827',
557 'text' => '#f9fafb',
558 'link' => '#93c5fd',
559 'primary_bg' => '#2563eb',
560 'primary_text' => '#ffffff',
561 'secondary_bg' => '#1f2937',
562 'secondary_text' => '#ffffff',
563 'border' => '#1f2937',
564 ],
565 ],
566 'behavior' => [
567 'show_on' => 'all',
568 'include_pages' => '',
569 'exclude_pages' => '',
570 'resurface' => 'version',
571 'scroll_consent' => false,
572 'click_consent' => false,
573 ],
574 'scripts' => [],
575 'manual_blocks' => [],
576 'data_attribute_support' => false,
577 'advanced' => [
578 'cookie_name' => self::CONSENT_COOKIE,
579 'cookie_path' => '/',
580 'cookie_domain' => '',
581 'same_site' => 'Lax',
582 'secure' => false,
583 'storage' => 'cookie',
584 ],
585 'logs' => [
586 'enabled' => false,
587 'retention' => '365',
588 ],
589 ];
590 }
591
592 /**
593 * Sanitizes incoming options.
594 *
595 * @param array<string, mixed> $raw Raw request data.
596 * @return array<string, mixed>
597 */
598 private function sanitize_options(array $raw): array
599 {
600 $defaults = $this->get_default_options();
601
602 $enabled = isset($raw['enabled']) ? (bool) $raw['enabled'] : false;
603 $mode = isset($raw['mode']) ? sanitize_text_field(wp_unslash($raw['mode'])) : $defaults['mode'];
604 $region = isset($raw['region_targeting']) ? sanitize_text_field(wp_unslash($raw['region_targeting'])) : $defaults['region_targeting'];
605 $custom_regions = isset($raw['custom_regions']) ? sanitize_textarea_field(wp_unslash($raw['custom_regions'])) : '';
606 $consent_lifetime = isset($raw['consent_lifetime']) ? sanitize_text_field(wp_unslash($raw['consent_lifetime'])) : $defaults['consent_lifetime'];
607 $policy_version = isset($raw['policy_version']) ? sanitize_text_field(wp_unslash($raw['policy_version'])) : $defaults['policy_version'];
608 $template = isset($raw['template']) ? sanitize_text_field(wp_unslash($raw['template'])) : $defaults['template'];
609
610 $content = $defaults['content'];
611 if (isset($raw['content']) && is_array($raw['content'])) {
612 $content = [
613 'title' => isset($raw['content']['title']) ? sanitize_text_field(wp_unslash($raw['content']['title'])) : $content['title'],
614 'message' => isset($raw['content']['message']) ? sanitize_textarea_field(wp_unslash($raw['content']['message'])) : $content['message'],
615 'privacy_label' => isset($raw['content']['privacy_label']) ? sanitize_text_field(wp_unslash($raw['content']['privacy_label'])) : $content['privacy_label'],
616 'privacy_url' => isset($raw['content']['privacy_url']) ? esc_url_raw(wp_unslash($raw['content']['privacy_url'])) : '',
617 'cookie_label' => isset($raw['content']['cookie_label']) ? sanitize_text_field(wp_unslash($raw['content']['cookie_label'])) : $content['cookie_label'],
618 'cookie_url' => isset($raw['content']['cookie_url']) ? esc_url_raw(wp_unslash($raw['content']['cookie_url'])) : '',
619 'cookie_url_custom' => isset($raw['content']['cookie_url_custom']) ? esc_url_raw(wp_unslash($raw['content']['cookie_url_custom'])) : '',
620 ];
621 }
622
623 $buttons = $defaults['buttons'];
624 if (isset($raw['buttons']) && is_array($raw['buttons'])) {
625 $buttons = [
626 'accept' => isset($raw['buttons']['accept']) ? sanitize_text_field(wp_unslash($raw['buttons']['accept'])) : $buttons['accept'],
627 'reject' => isset($raw['buttons']['reject']) ? sanitize_text_field(wp_unslash($raw['buttons']['reject'])) : $buttons['reject'],
628 'settings' => isset($raw['buttons']['settings']) ? sanitize_text_field(wp_unslash($raw['buttons']['settings'])) : $buttons['settings'],
629 'save' => isset($raw['buttons']['save']) ? sanitize_text_field(wp_unslash($raw['buttons']['save'])) : $buttons['save'],
630 ];
631 }
632
633 $categories = $this->sanitize_categories($raw, $defaults['categories']);
634
635 $design = $defaults['design'];
636 if (isset($raw['design']) && is_array($raw['design'])) {
637 $design['layout'] = isset($raw['design']['layout']) ? sanitize_text_field(wp_unslash($raw['design']['layout'])) : $design['layout'];
638 $design['preset'] = isset($raw['design']['preset']) ? sanitize_text_field(wp_unslash($raw['design']['preset'])) : $design['preset'];
639 $design['position'] = isset($raw['design']['position']) ? sanitize_text_field(wp_unslash($raw['design']['position'])) : $design['position'];
640 $design['width'] = isset($raw['design']['width']) ? sanitize_text_field(wp_unslash($raw['design']['width'])) : $design['width'];
641 $design['animation'] = isset($raw['design']['animation']) ? sanitize_text_field(wp_unslash($raw['design']['animation'])) : $design['animation'];
642 $design['shadow'] = isset($raw['design']['shadow']) ? (bool) $raw['design']['shadow'] : false;
643 $design['border_radius'] = isset($raw['design']['border_radius']) ? absint($raw['design']['border_radius']) : $design['border_radius'];
644
645 if (isset($raw['design']['colors']) && is_array($raw['design']['colors'])) {
646 foreach ($design['colors'] as $key => $color_default) {
647 if (isset($raw['design']['colors'][$key])) {
648 $design['colors'][$key] = sanitize_hex_color(wp_unslash($raw['design']['colors'][$key]));
649 }
650 }
651 }
652 }
653
654 $behavior = $defaults['behavior'];
655 if (isset($raw['behavior']) && is_array($raw['behavior'])) {
656 $behavior['show_on'] = isset($raw['behavior']['show_on']) ? sanitize_text_field(wp_unslash($raw['behavior']['show_on'])) : $behavior['show_on'];
657 $behavior['include_pages'] = isset($raw['behavior']['include_pages']) ? sanitize_textarea_field(wp_unslash($raw['behavior']['include_pages'])) : '';
658 $behavior['exclude_pages'] = isset($raw['behavior']['exclude_pages']) ? sanitize_textarea_field(wp_unslash($raw['behavior']['exclude_pages'])) : '';
659 $behavior['resurface'] = isset($raw['behavior']['resurface']) ? sanitize_text_field(wp_unslash($raw['behavior']['resurface'])) : $behavior['resurface'];
660 $behavior['scroll_consent'] = isset($raw['behavior']['scroll_consent']) ? (bool) $raw['behavior']['scroll_consent'] : false;
661 $behavior['click_consent'] = isset($raw['behavior']['click_consent']) ? (bool) $raw['behavior']['click_consent'] : false;
662 }
663
664 $scripts = $this->sanitize_script_rules($raw);
665
666 $manual_blocks = [];
667 if ($this->is_premium() && isset($raw['manual_blocks']) && is_array($raw['manual_blocks'])) {
668 foreach ($raw['manual_blocks'] as $block) {
669 if (!isset($block['code']) || trim($block['code']) === '') {
670 continue;
671 }
672 $manual_blocks[] = [
673 'name' => isset($block['name']) ? sanitize_text_field(wp_unslash($block['name'])) : '',
674 'category' => isset($block['category']) ? sanitize_text_field(wp_unslash($block['category'])) : 'analytics',
675 'type' => isset($block['type']) ? sanitize_text_field(wp_unslash($block['type'])) : 'inline-js',
676 'code' => wp_kses_post(wp_unslash($block['code'])),
677 ];
678 }
679 }
680
681 $advanced = $defaults['advanced'];
682 if (isset($raw['advanced']) && is_array($raw['advanced'])) {
683 $advanced['cookie_name'] = isset($raw['advanced']['cookie_name']) ? sanitize_key(wp_unslash($raw['advanced']['cookie_name'])) : $advanced['cookie_name'];
684 $advanced['cookie_path'] = isset($raw['advanced']['cookie_path']) ? sanitize_text_field(wp_unslash($raw['advanced']['cookie_path'])) : $advanced['cookie_path'];
685 $advanced['cookie_domain'] = isset($raw['advanced']['cookie_domain']) ? sanitize_text_field(wp_unslash($raw['advanced']['cookie_domain'])) : '';
686
687 $same_site = isset($raw['advanced']['same_site']) ? sanitize_text_field(wp_unslash($raw['advanced']['same_site'])) : $advanced['same_site'];
688 $same_site = ucfirst(strtolower($same_site));
689 if (!in_array($same_site, ['Lax', 'Strict', 'None'], true)) {
690 $same_site = $advanced['same_site'];
691 }
692 $advanced['same_site'] = $same_site;
693
694 $secure = isset($raw['advanced']['secure']) ? (bool) $raw['advanced']['secure'] : false;
695 if ($advanced['same_site'] === 'None') {
696 $secure = true;
697 }
698 $advanced['secure'] = $secure;
699
700 $storage = isset($raw['advanced']['storage']) ? sanitize_text_field(wp_unslash($raw['advanced']['storage'])) : $advanced['storage'];
701 $storage = strtolower($storage);
702 if (!in_array($storage, ['cookie', 'local'], true)) {
703 $storage = $advanced['storage'];
704 }
705 $advanced['storage'] = $storage;
706 }
707
708 $logs = $defaults['logs'];
709 if (isset($raw['logs']) && is_array($raw['logs'])) {
710 $logs['enabled'] = isset($raw['logs']['enabled']) ? (bool) $raw['logs']['enabled'] : false;
711 $logs['retention'] = isset($raw['logs']['retention']) ? sanitize_text_field(wp_unslash($raw['logs']['retention'])) : $logs['retention'];
712 }
713
714 $data_attribute_support = $this->is_premium() && isset($raw['data_attribute_support']) ? (bool) $raw['data_attribute_support'] : false;
715
716 return [
717 'enabled' => $enabled,
718 'mode' => $mode,
719 'region_targeting' => $region,
720 'custom_regions' => $custom_regions,
721 'consent_lifetime' => $consent_lifetime,
722 'policy_version' => $policy_version,
723 'template' => $template,
724 'content' => $content,
725 'buttons' => $buttons,
726 'categories' => $categories,
727 'design' => $design,
728 'behavior' => $behavior,
729 'scripts' => $scripts,
730 'manual_blocks' => $manual_blocks,
731 'data_attribute_support' => $data_attribute_support,
732 'advanced' => $advanced,
733 'logs' => $logs,
734 ];
735 }
736
737 /**
738 * Sanitizes categories including Pro-only additions.
739 *
740 * @param array<string, mixed> $raw Raw input.
741 * @param array<int, array<string, mixed>> $defaults Defaults.
742 * @return array<int, array<string, mixed>>
743 */
744 private function sanitize_categories(array $raw, array $defaults): array
745 {
746 $categories = $defaults;
747
748 if (isset($raw['categories']) && is_array($raw['categories'])) {
749 $categories = [];
750 foreach ($raw['categories'] as $category) {
751 if (!isset($category['key'])) {
752 continue;
753 }
754 $key = sanitize_key($category['key']);
755 $label = isset($category['label']) ? sanitize_text_field(wp_unslash($category['label'])) : '';
756 $description = isset($category['description']) ? sanitize_textarea_field(wp_unslash($category['description'])) : '';
757 $state = isset($category['state']) ? sanitize_text_field(wp_unslash($category['state'])) : 'off';
758 $display = isset($category['display']) ? (bool) $category['display'] : true;
759
760 if ($key === 'necessary') {
761 $state = 'required';
762 $display = true;
763 }
764
765 $categories[] = [
766 'key' => $key,
767 'label' => $label ?: ucfirst($key),
768 'description' => $description,
769 'state' => $state,
770 'display' => $display,
771 ];
772 }
773 }
774
775 if (!$this->is_premium()) {
776 $categories = array_slice($categories, 0, 4);
777 }
778
779 return $categories;
780 }
781
782 /**
783 * Sanitizes script blocking rules.
784 *
785 * @param array<string, mixed> $raw Raw input.
786 * @return array<string, array<string, string>>
787 */
788 private function sanitize_script_rules(array $raw): array
789 {
790 $rules = [];
791 if (isset($raw['scripts']) && is_array($raw['scripts'])) {
792 foreach ($raw['scripts'] as $rule) {
793 if (empty($rule['handle'])) {
794 continue;
795 }
796
797 $handle = sanitize_key(wp_unslash($rule['handle']));
798 $category = isset($rule['category']) ? sanitize_key(wp_unslash($rule['category'])) : 'analytics';
799 $mode = isset($rule['mode']) ? sanitize_text_field(wp_unslash($rule['mode'])) : 'block';
800
801 if (!in_array($mode, ['block', 'allow'], true)) {
802 $mode = 'block';
803 }
804
805 $rules[$handle] = [
806 'handle' => $handle,
807 'category' => $category,
808 'mode' => $mode,
809 ];
810 }
811 }
812
813 if (!$this->is_premium() && count($rules) > self::FREE_SCRIPT_LIMIT) {
814 $rules = array_slice($rules, 0, self::FREE_SCRIPT_LIMIT, true);
815 }
816
817 return $rules;
818 }
819
820 /**
821 * Determines if frontend should render.
822 *
823 * @return bool
824 */
825 private function should_render(): bool
826 {
827 if (is_admin()) {
828 return false;
829 }
830
831 if (!$this->options['enabled']) {
832 return false;
833 }
834
835 if (!$this->passes_page_rules()) {
836 return false;
837 }
838
839 if (!$this->passes_region_rules()) {
840 return false;
841 }
842
843 return true;
844 }
845
846 /**
847 * Checks page targeting rules.
848 *
849 * @return bool
850 */
851 private function passes_page_rules(): bool
852 {
853 $behavior = $this->options['behavior'];
854
855 if (!$this->is_premium()) {
856 return true;
857 }
858
859 if ($behavior['show_on'] === 'all') {
860 return true;
861 }
862
863 if ($behavior['show_on'] === 'include') {
864 $ids = array_filter(array_map('absint', explode(',', $behavior['include_pages'])));
865 return $this->current_page_matches_ids($ids);
866 }
867
868 if ($behavior['show_on'] === 'exclude') {
869 $ids = array_filter(array_map('absint', explode(',', $behavior['exclude_pages'])));
870 return !$this->current_page_matches_ids($ids);
871 }
872
873 return true;
874 }
875
876 /**
877 * True when the current request is one of the given page IDs.
878 * WooCommerce's shop is a product archive, so is_page() is false there.
879 *
880 * @param array<int, int> $ids Page IDs.
881 */
882 private function current_page_matches_ids(array $ids): bool
883 {
884 $ids = array_values(array_filter(array_map('absint', $ids)));
885 if ($ids === []) {
886 return false;
887 }
888
889 if (is_page($ids)) {
890 return true;
891 }
892
893 if (function_exists('is_shop') && is_shop() && function_exists('wc_get_page_id')) {
894 return in_array((int) wc_get_page_id('shop'), $ids, true);
895 }
896
897 return false;
898 }
899
900 /**
901 * Checks region targeting rules.
902 *
903 * @return bool
904 */
905 private function passes_region_rules(): bool
906 {
907 $target = $this->options['region_targeting'];
908
909 if ($target === 'all') {
910 return true;
911 }
912
913 $region = $this->resolve_region();
914
915 // Fail-open for privacy compliance: if we cannot resolve region reliably, show the banner.
916 if ($region === 'unknown') {
917 return true;
918 }
919
920 if ($target === 'eu') {
921 return $region === 'eu';
922 }
923
924 if ($target === 'us') {
925 return $region === 'us';
926 }
927
928 if ($target === 'custom' && $this->is_premium()) {
929 $raw = array_filter(array_map('trim', explode(',', (string) $this->options['custom_regions'])));
930 $countries = [];
931 foreach ($raw as $code) {
932 $code = (string) $code;
933 $code_lower = strtolower($code);
934 if ($code_lower === 'eu' || $code_lower === 'us') {
935 $countries[] = $code_lower;
936 continue;
937 }
938 $countries[] = strtoupper($code);
939 }
940
941 $region_norm = $region;
942 if ($region !== 'eu' && $region !== 'us') {
943 $region_norm = strtoupper($region);
944 }
945
946 return in_array($region_norm, $countries, true);
947 }
948
949 return true;
950 }
951
952 /**
953 * Gets consent data from cookie.
954 *
955 * @return array<string, mixed>
956 */
957 private function get_consent_cookie(): array
958 {
959 $name = $this->options['advanced']['cookie_name'];
960 if (!isset($_COOKIE[$name])) {
961 return [];
962 }
963
964 $decoded = json_decode(stripslashes($_COOKIE[$name]), true);
965 if (!is_array($decoded)) {
966 return [];
967 }
968
969 return $decoded;
970 }
971
972 /**
973 * Determines if a category has consent.
974 *
975 * @param string $category Category key.
976 * @return bool
977 */
978 private function has_consent_for_category(string $category): bool
979 {
980 if ($category === 'necessary') {
981 return true;
982 }
983
984 $consent = $this->get_consent_cookie();
985 if (!isset($consent['categories']) || !is_array($consent['categories'])) {
986 return false;
987 }
988
989 return in_array($category, $consent['categories'], true);
990 }
991
992 /**
993 * Builds frontend payload for JS.
994 *
995 * @return array<string, mixed>
996 */
997 private function get_frontend_payload(): array
998 {
999 $categories = $this->options['categories'];
1000 if (!$this->is_premium()) {
1001 $categories = array_slice($categories, 0, 4);
1002 }
1003
1004 $region = 'all';
1005 if (($this->options['region_targeting'] ?? 'all') !== 'all') {
1006 $region = $this->resolve_region();
1007 }
1008
1009 return [
1010 'options' => [
1011 'mode' => $this->options['mode'],
1012 'template' => $this->options['template'],
1013 'content' => $this->options['content'],
1014 'buttons' => $this->options['buttons'],
1015 'categories' => $categories,
1016 'design' => $this->options['design'],
1017 'behavior' => $this->options['behavior'],
1018 'manualBlocks' => $this->is_premium() ? $this->options['manual_blocks'] : [],
1019 'scripts' => array_values($this->get_script_rules()),
1020 'dataAttributes' => $this->options['data_attribute_support'],
1021 'advanced' => $this->options['advanced'],
1022 'policyVersion' => $this->options['policy_version'],
1023 'consentLifetime' => $this->options['consent_lifetime'],
1024 'isPremium' => $this->is_premium(),
1025 'logsEnabled' => $this->is_premium() && $this->options['logs']['enabled'],
1026 ],
1027 'ajax' => [
1028 'url' => admin_url('admin-ajax.php'),
1029 'nonce' => wp_create_nonce('king_addons_cookie_log'),
1030 ],
1031 'region' => $region,
1032 ];
1033 }
1034
1035 /**
1036 * Returns script blocking rules keyed by handle.
1037 *
1038 * @return array<string, array<string, string>>
1039 */
1040 private function get_script_rules(): array
1041 {
1042 return $this->options['scripts'];
1043 }
1044
1045 /**
1046 * Determines visitor region with caching and filter hook.
1047 *
1048 * @return string
1049 */
1050 private function resolve_region(): string
1051 {
1052 $cache_key = $this->get_geo_cache_key();
1053 $cached = get_transient($cache_key);
1054 if ($cached) {
1055 return (string) $cached;
1056 }
1057
1058 $region = 'unknown';
1059 $response = wp_remote_get(
1060 'https://ipapi.co/json/',
1061 [
1062 'timeout' => 2,
1063 'redirection' => 2,
1064 'user-agent' => 'KingAddonsCookieConsent/' . (defined('KING_ADDONS_VERSION') ? KING_ADDONS_VERSION : 'unknown'),
1065 ]
1066 );
1067 if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
1068 $data = json_decode(wp_remote_retrieve_body($response), true);
1069 if (isset($data['country_code'])) {
1070 $country = strtoupper(sanitize_text_field($data['country_code']));
1071 $eu_countries = $this->get_eu_countries();
1072 if (in_array($country, $eu_countries, true)) {
1073 $region = 'eu';
1074 } elseif ($country === 'US') {
1075 $region = 'us';
1076 } else {
1077 $region = $country;
1078 }
1079 }
1080 }
1081
1082 $region = apply_filters('king_addons_cookie_consent_region', $region);
1083 set_transient($cache_key, $region, self::GEO_TTL);
1084
1085 return $region;
1086 }
1087
1088 /**
1089 * Inserts a log row.
1090 *
1091 * @param string $action Action name.
1092 * @param array<int, string> $categories Consent categories.
1093 * @param string $region Region.
1094 * @param string $device Device descriptor.
1095 * @return void
1096 */
1097 private function insert_log_row(string $action, array $categories, string $region, string $device): void
1098 {
1099 global $wpdb;
1100 $table = $wpdb->prefix . self::LOG_TABLE;
1101
1102 $wpdb->insert(
1103 $table,
1104 [
1105 'action' => $action,
1106 'categories' => wp_json_encode($categories),
1107 'region' => $region,
1108 'device' => $device,
1109 'logged_at' => current_time('mysql', true),
1110 ],
1111 ['%s', '%s', '%s', '%s', '%s']
1112 );
1113 }
1114
1115 /**
1116 * Creates the log table if missing.
1117 *
1118 * @return void
1119 */
1120 private function maybe_create_log_table(): void
1121 {
1122 global $wpdb;
1123 $table = $wpdb->prefix . self::LOG_TABLE;
1124
1125 $charset = $wpdb->get_charset_collate();
1126 $sql = "CREATE TABLE {$table} (
1127 id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
1128 action varchar(50) NOT NULL,
1129 categories text NULL,
1130 region varchar(20) NULL,
1131 device varchar(50) NULL,
1132 logged_at datetime NOT NULL,
1133 PRIMARY KEY (id)
1134 ) {$charset};";
1135
1136 require_once ABSPATH . 'wp-admin/includes/upgrade.php';
1137 dbDelta($sql);
1138 }
1139
1140 /**
1141 * Returns a list of EU country codes.
1142 *
1143 * @return array<int, string>
1144 */
1145 private function get_eu_countries(): array
1146 {
1147 return [
1148 'AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR', 'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL', 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE',
1149 ];
1150 }
1151
1152 /**
1153 * Builds a geo cache key scoped per IP.
1154 *
1155 * @return string
1156 */
1157 private function get_geo_cache_key(): string
1158 {
1159 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown';
1160 return self::GEO_TRANSIENT . '_' . md5($ip);
1161 }
1162
1163 /**
1164 * Indicates whether Pro is active.
1165 *
1166 * @return bool
1167 */
1168 private function is_premium(): bool
1169 {
1170 if (!function_exists('king_addons_freemius')) {
1171 return false;
1172 }
1173
1174 $fs = king_addons_freemius();
1175 if (!is_object($fs) || !method_exists($fs, 'can_use_premium_code__premium_only')) {
1176 return false;
1177 }
1178
1179 return $fs->can_use_premium_code__premium_only();
1180 }
1181 }
1182
1183
1184
1185