PluginProbe
King Addons for Elementor – 100+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce Builder, Mega Menu, Popup Builder / 51.1.44
King Addons for Elementor – 100+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce Builder, Mega Menu, Popup Builder v51.1.44
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.44, at includes/extensions/Cookie_Consent/Cookie_Consent.php

1,070 lines 37.4 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' => esc_html__('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 KING_ADDONS_VERSION
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 if ($this->has_consent_for_category($category)) {
374 return $tag;
375 }
376
377 // Normalize: remove existing type then force text/plain with category marker
378 $tag = preg_replace('/\s*type=("|\')?text\/javascript("|\')?/i', '', $tag);
379 $blocked_tag = str_replace(
380 '<script ',
381 '<script type="text/plain" data-ka-cookie-category="' . esc_attr($category) . '" ',
382 $tag
383 );
384
385 return $blocked_tag;
386 }
387
388 /**
389 * Logs consent events via AJAX (Pro).
390 *
391 * @return void
392 */
393 public function ajax_log_consent(): void
394 {
395 if (!$this->is_premium()) {
396 wp_send_json_success(['message' => 'logging-disabled']);
397 }
398
399 check_ajax_referer('king_addons_cookie_log');
400
401 $action = isset($_POST['actionType']) ? sanitize_text_field(wp_unslash($_POST['actionType'])) : '';
402 $categories = isset($_POST['categories']) && is_array($_POST['categories']) ? array_map('sanitize_text_field', wp_unslash($_POST['categories'])) : [];
403 $region = isset($_POST['region']) ? sanitize_text_field(wp_unslash($_POST['region'])) : 'unknown';
404 $device = isset($_POST['device']) ? sanitize_text_field(wp_unslash($_POST['device'])) : 'unknown';
405
406 $this->maybe_create_log_table();
407 $this->insert_log_row($action, $categories, $region, $device);
408
409 wp_send_json_success(['message' => 'logged']);
410 }
411
412 /**
413 * Renders a shortcode manage button.
414 *
415 * @param array<string, mixed> $atts Shortcode attributes.
416 * @return string
417 */
418 public function render_manage_button_shortcode(array $atts = []): string
419 {
420 $atts = shortcode_atts(
421 [
422 'label' => esc_html__('Cookie settings', 'king-addons'),
423 'class' => '',
424 ],
425 $atts
426 );
427
428 $class = trim('king-addons-cookie-manage ' . sanitize_html_class($atts['class']));
429
430 return '<button type="button" class="' . esc_attr($class) . '" data-ka-cookie-manage="true">' . esc_html($atts['label']) . '</button>';
431 }
432
433 /**
434 * Retrieves options with defaults.
435 *
436 * @return array<string, mixed>
437 */
438 private function get_options(): array
439 {
440 $stored = get_option(self::OPTION_NAME, []);
441 $defaults = $this->get_default_options();
442
443 if (!is_array($stored)) {
444 $stored = [];
445 }
446
447 return array_replace_recursive($defaults, $stored);
448 }
449
450 /**
451 * Returns default option set.
452 *
453 * @return array<string, mixed>
454 */
455 private function get_default_options(): array
456 {
457 return [
458 'enabled' => true,
459 'mode' => 'gdpr',
460 'region_targeting' => 'all',
461 'custom_regions' => '',
462 'consent_lifetime' => '365',
463 'policy_version' => '1',
464 'template' => 'gdpr_minimal',
465 'content' => [
466 'title' => esc_html__('We value your privacy', 'king-addons'),
467 'message' => esc_html__('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'),
468 'privacy_label' => esc_html__('Privacy Policy', 'king-addons'),
469 'privacy_url' => '',
470 'cookie_label' => esc_html__('Cookie Policy', 'king-addons'),
471 'cookie_url' => '',
472 'cookie_url_custom' => '',
473 ],
474 'buttons' => [
475 'accept' => esc_html__('Accept all', 'king-addons'),
476 'reject' => esc_html__('Reject all', 'king-addons'),
477 'settings' => esc_html__('Cookie settings', 'king-addons'),
478 'save' => esc_html__('Save preferences', 'king-addons'),
479 ],
480 'categories' => [
481 [
482 'key' => 'necessary',
483 'label' => esc_html__('Strictly necessary', 'king-addons'),
484 'description' => esc_html__('Required for the site to function correctly. These cookies cannot be disabled.', 'king-addons'),
485 'state' => 'required',
486 'display' => true,
487 ],
488 [
489 'key' => 'analytics',
490 'label' => esc_html__('Analytics', 'king-addons'),
491 'description' => esc_html__('Helps us improve the site by collecting anonymous usage data about how you interact with it.', 'king-addons'),
492 'state' => 'off',
493 'display' => true,
494 ],
495 [
496 'key' => 'marketing',
497 'label' => esc_html__('Marketing', 'king-addons'),
498 'description' => esc_html__('Used to deliver personalized advertisements and measure their performance.', 'king-addons'),
499 'state' => 'off',
500 'display' => true,
501 ],
502 [
503 'key' => 'other',
504 'label' => esc_html__('Other', 'king-addons'),
505 'description' => esc_html__('Additional cookies that do not fit into other categories.', 'king-addons'),
506 'state' => 'off',
507 'display' => true,
508 ],
509 ],
510 'design' => [
511 'layout' => 'bottom-bar',
512 'preset' => 'light',
513 'position' => 'center',
514 'width' => 'full',
515 'animation' => 'fade',
516 'shadow' => true,
517 'border_radius' => 10,
518 'colors' => [
519 'background' => '#111827',
520 'text' => '#f9fafb',
521 'link' => '#93c5fd',
522 'primary_bg' => '#2563eb',
523 'primary_text' => '#ffffff',
524 'secondary_bg' => '#1f2937',
525 'secondary_text' => '#ffffff',
526 'border' => '#1f2937',
527 ],
528 ],
529 'behavior' => [
530 'show_on' => 'all',
531 'include_pages' => '',
532 'exclude_pages' => '',
533 'resurface' => 'version',
534 'scroll_consent' => false,
535 'click_consent' => false,
536 ],
537 'scripts' => [],
538 'manual_blocks' => [],
539 'data_attribute_support' => false,
540 'advanced' => [
541 'cookie_name' => self::CONSENT_COOKIE,
542 'cookie_path' => '/',
543 'cookie_domain' => '',
544 'same_site' => 'Lax',
545 'secure' => false,
546 'storage' => 'cookie',
547 ],
548 'logs' => [
549 'enabled' => true,
550 'retention' => '365',
551 ],
552 ];
553 }
554
555 /**
556 * Sanitizes incoming options.
557 *
558 * @param array<string, mixed> $raw Raw request data.
559 * @return array<string, mixed>
560 */
561 private function sanitize_options(array $raw): array
562 {
563 $defaults = $this->get_default_options();
564
565 $enabled = isset($raw['enabled']) ? (bool) $raw['enabled'] : false;
566 $mode = isset($raw['mode']) ? sanitize_text_field(wp_unslash($raw['mode'])) : $defaults['mode'];
567 $region = isset($raw['region_targeting']) ? sanitize_text_field(wp_unslash($raw['region_targeting'])) : $defaults['region_targeting'];
568 $custom_regions = isset($raw['custom_regions']) ? sanitize_textarea_field(wp_unslash($raw['custom_regions'])) : '';
569 $consent_lifetime = isset($raw['consent_lifetime']) ? sanitize_text_field(wp_unslash($raw['consent_lifetime'])) : $defaults['consent_lifetime'];
570 $policy_version = isset($raw['policy_version']) ? sanitize_text_field(wp_unslash($raw['policy_version'])) : $defaults['policy_version'];
571 $template = isset($raw['template']) ? sanitize_text_field(wp_unslash($raw['template'])) : $defaults['template'];
572
573 $content = $defaults['content'];
574 if (isset($raw['content']) && is_array($raw['content'])) {
575 $content = [
576 'title' => isset($raw['content']['title']) ? sanitize_text_field(wp_unslash($raw['content']['title'])) : $content['title'],
577 'message' => isset($raw['content']['message']) ? sanitize_textarea_field(wp_unslash($raw['content']['message'])) : $content['message'],
578 'privacy_label' => isset($raw['content']['privacy_label']) ? sanitize_text_field(wp_unslash($raw['content']['privacy_label'])) : $content['privacy_label'],
579 'privacy_url' => isset($raw['content']['privacy_url']) ? esc_url_raw(wp_unslash($raw['content']['privacy_url'])) : '',
580 'cookie_label' => isset($raw['content']['cookie_label']) ? sanitize_text_field(wp_unslash($raw['content']['cookie_label'])) : $content['cookie_label'],
581 'cookie_url' => isset($raw['content']['cookie_url']) ? esc_url_raw(wp_unslash($raw['content']['cookie_url'])) : '',
582 'cookie_url_custom' => isset($raw['content']['cookie_url_custom']) ? esc_url_raw(wp_unslash($raw['content']['cookie_url_custom'])) : '',
583 ];
584 }
585
586 $buttons = $defaults['buttons'];
587 if (isset($raw['buttons']) && is_array($raw['buttons'])) {
588 $buttons = [
589 'accept' => isset($raw['buttons']['accept']) ? sanitize_text_field(wp_unslash($raw['buttons']['accept'])) : $buttons['accept'],
590 'reject' => isset($raw['buttons']['reject']) ? sanitize_text_field(wp_unslash($raw['buttons']['reject'])) : $buttons['reject'],
591 'settings' => isset($raw['buttons']['settings']) ? sanitize_text_field(wp_unslash($raw['buttons']['settings'])) : $buttons['settings'],
592 'save' => isset($raw['buttons']['save']) ? sanitize_text_field(wp_unslash($raw['buttons']['save'])) : $buttons['save'],
593 ];
594 }
595
596 $categories = $this->sanitize_categories($raw, $defaults['categories']);
597
598 $design = $defaults['design'];
599 if (isset($raw['design']) && is_array($raw['design'])) {
600 $design['layout'] = isset($raw['design']['layout']) ? sanitize_text_field(wp_unslash($raw['design']['layout'])) : $design['layout'];
601 $design['preset'] = isset($raw['design']['preset']) ? sanitize_text_field(wp_unslash($raw['design']['preset'])) : $design['preset'];
602 $design['position'] = isset($raw['design']['position']) ? sanitize_text_field(wp_unslash($raw['design']['position'])) : $design['position'];
603 $design['width'] = isset($raw['design']['width']) ? sanitize_text_field(wp_unslash($raw['design']['width'])) : $design['width'];
604 $design['animation'] = isset($raw['design']['animation']) ? sanitize_text_field(wp_unslash($raw['design']['animation'])) : $design['animation'];
605 $design['shadow'] = isset($raw['design']['shadow']) ? (bool) $raw['design']['shadow'] : false;
606 $design['border_radius'] = isset($raw['design']['border_radius']) ? absint($raw['design']['border_radius']) : $design['border_radius'];
607
608 if (isset($raw['design']['colors']) && is_array($raw['design']['colors'])) {
609 foreach ($design['colors'] as $key => $color_default) {
610 if (isset($raw['design']['colors'][$key])) {
611 $design['colors'][$key] = sanitize_hex_color(wp_unslash($raw['design']['colors'][$key]));
612 }
613 }
614 }
615 }
616
617 $behavior = $defaults['behavior'];
618 if (isset($raw['behavior']) && is_array($raw['behavior'])) {
619 $behavior['show_on'] = isset($raw['behavior']['show_on']) ? sanitize_text_field(wp_unslash($raw['behavior']['show_on'])) : $behavior['show_on'];
620 $behavior['include_pages'] = isset($raw['behavior']['include_pages']) ? sanitize_textarea_field(wp_unslash($raw['behavior']['include_pages'])) : '';
621 $behavior['exclude_pages'] = isset($raw['behavior']['exclude_pages']) ? sanitize_textarea_field(wp_unslash($raw['behavior']['exclude_pages'])) : '';
622 $behavior['resurface'] = isset($raw['behavior']['resurface']) ? sanitize_text_field(wp_unslash($raw['behavior']['resurface'])) : $behavior['resurface'];
623 $behavior['scroll_consent'] = isset($raw['behavior']['scroll_consent']) ? (bool) $raw['behavior']['scroll_consent'] : false;
624 $behavior['click_consent'] = isset($raw['behavior']['click_consent']) ? (bool) $raw['behavior']['click_consent'] : false;
625 }
626
627 $scripts = $this->sanitize_script_rules($raw);
628
629 $manual_blocks = [];
630 if ($this->is_premium() && isset($raw['manual_blocks']) && is_array($raw['manual_blocks'])) {
631 foreach ($raw['manual_blocks'] as $block) {
632 if (!isset($block['code']) || trim($block['code']) === '') {
633 continue;
634 }
635 $manual_blocks[] = [
636 'name' => isset($block['name']) ? sanitize_text_field(wp_unslash($block['name'])) : '',
637 'category' => isset($block['category']) ? sanitize_text_field(wp_unslash($block['category'])) : 'analytics',
638 'type' => isset($block['type']) ? sanitize_text_field(wp_unslash($block['type'])) : 'inline-js',
639 'code' => wp_kses_post(wp_unslash($block['code'])),
640 ];
641 }
642 }
643
644 $advanced = $defaults['advanced'];
645 if (isset($raw['advanced']) && is_array($raw['advanced'])) {
646 $advanced['cookie_name'] = isset($raw['advanced']['cookie_name']) ? sanitize_key(wp_unslash($raw['advanced']['cookie_name'])) : $advanced['cookie_name'];
647 $advanced['cookie_path'] = isset($raw['advanced']['cookie_path']) ? sanitize_text_field(wp_unslash($raw['advanced']['cookie_path'])) : $advanced['cookie_path'];
648 $advanced['cookie_domain'] = isset($raw['advanced']['cookie_domain']) ? sanitize_text_field(wp_unslash($raw['advanced']['cookie_domain'])) : '';
649 $advanced['same_site'] = isset($raw['advanced']['same_site']) ? sanitize_text_field(wp_unslash($raw['advanced']['same_site'])) : $advanced['same_site'];
650 $advanced['secure'] = isset($raw['advanced']['secure']) ? (bool) $raw['advanced']['secure'] : false;
651 $advanced['storage'] = isset($raw['advanced']['storage']) ? sanitize_text_field(wp_unslash($raw['advanced']['storage'])) : $advanced['storage'];
652 }
653
654 $logs = $defaults['logs'];
655 if (isset($raw['logs']) && is_array($raw['logs'])) {
656 $logs['enabled'] = isset($raw['logs']['enabled']) ? (bool) $raw['logs']['enabled'] : false;
657 $logs['retention'] = isset($raw['logs']['retention']) ? sanitize_text_field(wp_unslash($raw['logs']['retention'])) : $logs['retention'];
658 }
659
660 $data_attribute_support = $this->is_premium() && isset($raw['data_attribute_support']) ? (bool) $raw['data_attribute_support'] : false;
661
662 return [
663 'enabled' => $enabled,
664 'mode' => $mode,
665 'region_targeting' => $region,
666 'custom_regions' => $custom_regions,
667 'consent_lifetime' => $consent_lifetime,
668 'policy_version' => $policy_version,
669 'template' => $template,
670 'content' => $content,
671 'buttons' => $buttons,
672 'categories' => $categories,
673 'design' => $design,
674 'behavior' => $behavior,
675 'scripts' => $scripts,
676 'manual_blocks' => $manual_blocks,
677 'data_attribute_support' => $data_attribute_support,
678 'advanced' => $advanced,
679 'logs' => $logs,
680 ];
681 }
682
683 /**
684 * Sanitizes categories including Pro-only additions.
685 *
686 * @param array<string, mixed> $raw Raw input.
687 * @param array<int, array<string, mixed>> $defaults Defaults.
688 * @return array<int, array<string, mixed>>
689 */
690 private function sanitize_categories(array $raw, array $defaults): array
691 {
692 $categories = $defaults;
693
694 if (isset($raw['categories']) && is_array($raw['categories'])) {
695 $categories = [];
696 foreach ($raw['categories'] as $category) {
697 if (!isset($category['key'])) {
698 continue;
699 }
700 $key = sanitize_key($category['key']);
701 $label = isset($category['label']) ? sanitize_text_field(wp_unslash($category['label'])) : '';
702 $description = isset($category['description']) ? sanitize_textarea_field(wp_unslash($category['description'])) : '';
703 $state = isset($category['state']) ? sanitize_text_field(wp_unslash($category['state'])) : 'off';
704 $display = isset($category['display']) ? (bool) $category['display'] : true;
705
706 if ($key === 'necessary') {
707 $state = 'required';
708 $display = true;
709 }
710
711 $categories[] = [
712 'key' => $key,
713 'label' => $label ?: ucfirst($key),
714 'description' => $description,
715 'state' => $state,
716 'display' => $display,
717 ];
718 }
719 }
720
721 if (!$this->is_premium()) {
722 $categories = array_slice($categories, 0, 4);
723 }
724
725 return $categories;
726 }
727
728 /**
729 * Sanitizes script blocking rules.
730 *
731 * @param array<string, mixed> $raw Raw input.
732 * @return array<string, array<string, string>>
733 */
734 private function sanitize_script_rules(array $raw): array
735 {
736 $rules = [];
737 if (isset($raw['scripts']) && is_array($raw['scripts'])) {
738 foreach ($raw['scripts'] as $rule) {
739 if (empty($rule['handle'])) {
740 continue;
741 }
742
743 $handle = sanitize_key(wp_unslash($rule['handle']));
744 $category = isset($rule['category']) ? sanitize_key(wp_unslash($rule['category'])) : 'analytics';
745 $mode = isset($rule['mode']) ? sanitize_text_field(wp_unslash($rule['mode'])) : 'block';
746
747 $rules[$handle] = [
748 'handle' => $handle,
749 'category' => $category,
750 'mode' => $mode,
751 ];
752 }
753 }
754
755 if (!$this->is_premium() && count($rules) > self::FREE_SCRIPT_LIMIT) {
756 $rules = array_slice($rules, 0, self::FREE_SCRIPT_LIMIT, true);
757 }
758
759 return $rules;
760 }
761
762 /**
763 * Determines if frontend should render.
764 *
765 * @return bool
766 */
767 private function should_render(): bool
768 {
769 if (is_admin()) {
770 return false;
771 }
772
773 if (!$this->options['enabled']) {
774 return false;
775 }
776
777 if (!$this->passes_page_rules()) {
778 return false;
779 }
780
781 if (!$this->passes_region_rules()) {
782 return false;
783 }
784
785 return true;
786 }
787
788 /**
789 * Checks page targeting rules.
790 *
791 * @return bool
792 */
793 private function passes_page_rules(): bool
794 {
795 $behavior = $this->options['behavior'];
796
797 if (!$this->is_premium()) {
798 return true;
799 }
800
801 if ($behavior['show_on'] === 'all') {
802 return true;
803 }
804
805 if ($behavior['show_on'] === 'include') {
806 $ids = array_filter(array_map('absint', explode(',', $behavior['include_pages'])));
807 return is_page($ids);
808 }
809
810 if ($behavior['show_on'] === 'exclude') {
811 $ids = array_filter(array_map('absint', explode(',', $behavior['exclude_pages'])));
812 return !is_page($ids);
813 }
814
815 return true;
816 }
817
818 /**
819 * Checks region targeting rules.
820 *
821 * @return bool
822 */
823 private function passes_region_rules(): bool
824 {
825 $target = $this->options['region_targeting'];
826
827 if ($target === 'all') {
828 return true;
829 }
830
831 $region = $this->resolve_region();
832
833 if ($target === 'eu') {
834 return $region === 'eu';
835 }
836
837 if ($target === 'us') {
838 return $region === 'us';
839 }
840
841 if ($target === 'custom' && $this->is_premium()) {
842 $countries = array_filter(array_map('trim', explode(',', $this->options['custom_regions'])));
843 return in_array($region, $countries, true);
844 }
845
846 return true;
847 }
848
849 /**
850 * Gets consent data from cookie.
851 *
852 * @return array<string, mixed>
853 */
854 private function get_consent_cookie(): array
855 {
856 $name = $this->options['advanced']['cookie_name'];
857 if (!isset($_COOKIE[$name])) {
858 return [];
859 }
860
861 $decoded = json_decode(stripslashes($_COOKIE[$name]), true);
862 if (!is_array($decoded)) {
863 return [];
864 }
865
866 return $decoded;
867 }
868
869 /**
870 * Determines if a category has consent.
871 *
872 * @param string $category Category key.
873 * @return bool
874 */
875 private function has_consent_for_category(string $category): bool
876 {
877 if ($category === 'necessary') {
878 return true;
879 }
880
881 $consent = $this->get_consent_cookie();
882 if (!isset($consent['categories']) || !is_array($consent['categories'])) {
883 return false;
884 }
885
886 return in_array($category, $consent['categories'], true);
887 }
888
889 /**
890 * Builds frontend payload for JS.
891 *
892 * @return array<string, mixed>
893 */
894 private function get_frontend_payload(): array
895 {
896 $categories = $this->options['categories'];
897 if (!$this->is_premium()) {
898 $categories = array_slice($categories, 0, 4);
899 }
900
901 return [
902 'options' => [
903 'mode' => $this->options['mode'],
904 'template' => $this->options['template'],
905 'content' => $this->options['content'],
906 'buttons' => $this->options['buttons'],
907 'categories' => $categories,
908 'design' => $this->options['design'],
909 'behavior' => $this->options['behavior'],
910 'manualBlocks' => $this->is_premium() ? $this->options['manual_blocks'] : [],
911 'scripts' => array_values($this->get_script_rules()),
912 'dataAttributes' => $this->options['data_attribute_support'],
913 'advanced' => $this->options['advanced'],
914 'policyVersion' => $this->options['policy_version'],
915 'consentLifetime' => $this->options['consent_lifetime'],
916 'isPremium' => $this->is_premium(),
917 'logsEnabled' => $this->is_premium() && $this->options['logs']['enabled'],
918 ],
919 'ajax' => [
920 'url' => admin_url('admin-ajax.php'),
921 'nonce' => wp_create_nonce('king_addons_cookie_log'),
922 ],
923 'region' => $this->resolve_region(),
924 ];
925 }
926
927 /**
928 * Returns script blocking rules keyed by handle.
929 *
930 * @return array<string, array<string, string>>
931 */
932 private function get_script_rules(): array
933 {
934 return $this->options['scripts'];
935 }
936
937 /**
938 * Determines visitor region with caching and filter hook.
939 *
940 * @return string
941 */
942 private function resolve_region(): string
943 {
944 $cache_key = $this->get_geo_cache_key();
945 $cached = get_transient($cache_key);
946 if ($cached) {
947 return (string) $cached;
948 }
949
950 $region = 'other';
951 $response = wp_remote_get('https://ipapi.co/json/');
952 if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
953 $data = json_decode(wp_remote_retrieve_body($response), true);
954 if (isset($data['country_code'])) {
955 $country = strtoupper(sanitize_text_field($data['country_code']));
956 $eu_countries = $this->get_eu_countries();
957 if (in_array($country, $eu_countries, true)) {
958 $region = 'eu';
959 } elseif ($country === 'US') {
960 $region = 'us';
961 } else {
962 $region = $country;
963 }
964 }
965 }
966
967 $region = apply_filters('king_addons_cookie_consent_region', $region);
968 set_transient($cache_key, $region, self::GEO_TTL);
969
970 return $region;
971 }
972
973 /**
974 * Inserts a log row.
975 *
976 * @param string $action Action name.
977 * @param array<int, string> $categories Consent categories.
978 * @param string $region Region.
979 * @param string $device Device descriptor.
980 * @return void
981 */
982 private function insert_log_row(string $action, array $categories, string $region, string $device): void
983 {
984 global $wpdb;
985 $table = $wpdb->prefix . self::LOG_TABLE;
986
987 $wpdb->insert(
988 $table,
989 [
990 'action' => $action,
991 'categories' => wp_json_encode($categories),
992 'region' => $region,
993 'device' => $device,
994 'logged_at' => current_time('mysql', true),
995 ],
996 ['%s', '%s', '%s', '%s', '%s']
997 );
998 }
999
1000 /**
1001 * Creates the log table if missing.
1002 *
1003 * @return void
1004 */
1005 private function maybe_create_log_table(): void
1006 {
1007 global $wpdb;
1008 $table = $wpdb->prefix . self::LOG_TABLE;
1009
1010 $charset = $wpdb->get_charset_collate();
1011 $sql = "CREATE TABLE {$table} (
1012 id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
1013 action varchar(50) NOT NULL,
1014 categories text NULL,
1015 region varchar(20) NULL,
1016 device varchar(50) NULL,
1017 logged_at datetime NOT NULL,
1018 PRIMARY KEY (id)
1019 ) {$charset};";
1020
1021 require_once ABSPATH . 'wp-admin/includes/upgrade.php';
1022 dbDelta($sql);
1023 }
1024
1025 /**
1026 * Returns a list of EU country codes.
1027 *
1028 * @return array<int, string>
1029 */
1030 private function get_eu_countries(): array
1031 {
1032 return [
1033 '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',
1034 ];
1035 }
1036
1037 /**
1038 * Builds a geo cache key scoped per IP.
1039 *
1040 * @return string
1041 */
1042 private function get_geo_cache_key(): string
1043 {
1044 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown';
1045 return self::GEO_TRANSIENT . '_' . md5($ip);
1046 }
1047
1048 /**
1049 * Indicates whether Pro is active.
1050 *
1051 * @return bool
1052 */
1053 private function is_premium(): bool
1054 {
1055 if (!function_exists('king_addons_freemius')) {
1056 return false;
1057 }
1058
1059 $fs = king_addons_freemius();
1060 if (!is_object($fs) || !method_exists($fs, 'can_use_premium_code__premium_only')) {
1061 return false;
1062 }
1063
1064 return $fs->can_use_premium_code__premium_only();
1065 }
1066 }
1067
1068
1069
1070