PluginProbe
King Addons for Elementor – 80+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce, Mega Menu, Popup Builder / 51.1.78
King Addons for Elementor – 80+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce, Mega Menu, Popup Builder v51.1.78
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 51.1.46 51.1.47 51.1.49 All 37 releases
king-addons / includes / extensions / Cookie_Consent / Cookie_Consent.php

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

1,157 lines 40.7 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 $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' => esc_html__('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 $stored = get_option(self::OPTION_NAME, []);
474 $defaults = $this->get_default_options();
475
476 if (!is_array($stored)) {
477 $stored = [];
478 }
479
480 return array_replace_recursive($defaults, $stored);
481 }
482
483 /**
484 * Returns default option set.
485 *
486 * @return array<string, mixed>
487 */
488 private function get_default_options(): array
489 {
490 return [
491 'enabled' => false,
492 'mode' => 'gdpr',
493 'region_targeting' => 'all',
494 'custom_regions' => '',
495 'consent_lifetime' => '365',
496 'policy_version' => '1',
497 'template' => 'gdpr_minimal',
498 'content' => [
499 'title' => esc_html__('We value your privacy', 'king-addons'),
500 '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'),
501 'privacy_label' => esc_html__('Privacy Policy', 'king-addons'),
502 'privacy_url' => '',
503 'cookie_label' => esc_html__('Cookie Policy', 'king-addons'),
504 'cookie_url' => '',
505 'cookie_url_custom' => '',
506 ],
507 'buttons' => [
508 'accept' => esc_html__('Accept all', 'king-addons'),
509 'reject' => esc_html__('Reject all', 'king-addons'),
510 'settings' => esc_html__('Cookie settings', 'king-addons'),
511 'save' => esc_html__('Save preferences', 'king-addons'),
512 ],
513 'categories' => [
514 [
515 'key' => 'necessary',
516 'label' => esc_html__('Strictly necessary', 'king-addons'),
517 'description' => esc_html__('Required for the site to function correctly. These cookies cannot be disabled.', 'king-addons'),
518 'state' => 'required',
519 'display' => true,
520 ],
521 [
522 'key' => 'analytics',
523 'label' => esc_html__('Analytics', 'king-addons'),
524 'description' => esc_html__('Helps us improve the site by collecting anonymous usage data about how you interact with it.', 'king-addons'),
525 'state' => 'off',
526 'display' => true,
527 ],
528 [
529 'key' => 'marketing',
530 'label' => esc_html__('Marketing', 'king-addons'),
531 'description' => esc_html__('Used to deliver personalized advertisements and measure their performance.', 'king-addons'),
532 'state' => 'off',
533 'display' => true,
534 ],
535 [
536 'key' => 'other',
537 'label' => esc_html__('Other', 'king-addons'),
538 'description' => esc_html__('Additional cookies that do not fit into other categories.', 'king-addons'),
539 'state' => 'off',
540 'display' => true,
541 ],
542 ],
543 'design' => [
544 'layout' => 'bottom-bar',
545 'preset' => 'light',
546 'position' => 'center',
547 'width' => 'full',
548 'animation' => 'fade',
549 'shadow' => true,
550 'border_radius' => 10,
551 'colors' => [
552 'background' => '#111827',
553 'text' => '#f9fafb',
554 'link' => '#93c5fd',
555 'primary_bg' => '#2563eb',
556 'primary_text' => '#ffffff',
557 'secondary_bg' => '#1f2937',
558 'secondary_text' => '#ffffff',
559 'border' => '#1f2937',
560 ],
561 ],
562 'behavior' => [
563 'show_on' => 'all',
564 'include_pages' => '',
565 'exclude_pages' => '',
566 'resurface' => 'version',
567 'scroll_consent' => false,
568 'click_consent' => false,
569 ],
570 'scripts' => [],
571 'manual_blocks' => [],
572 'data_attribute_support' => false,
573 'advanced' => [
574 'cookie_name' => self::CONSENT_COOKIE,
575 'cookie_path' => '/',
576 'cookie_domain' => '',
577 'same_site' => 'Lax',
578 'secure' => false,
579 'storage' => 'cookie',
580 ],
581 'logs' => [
582 'enabled' => false,
583 'retention' => '365',
584 ],
585 ];
586 }
587
588 /**
589 * Sanitizes incoming options.
590 *
591 * @param array<string, mixed> $raw Raw request data.
592 * @return array<string, mixed>
593 */
594 private function sanitize_options(array $raw): array
595 {
596 $defaults = $this->get_default_options();
597
598 $enabled = isset($raw['enabled']) ? (bool) $raw['enabled'] : false;
599 $mode = isset($raw['mode']) ? sanitize_text_field(wp_unslash($raw['mode'])) : $defaults['mode'];
600 $region = isset($raw['region_targeting']) ? sanitize_text_field(wp_unslash($raw['region_targeting'])) : $defaults['region_targeting'];
601 $custom_regions = isset($raw['custom_regions']) ? sanitize_textarea_field(wp_unslash($raw['custom_regions'])) : '';
602 $consent_lifetime = isset($raw['consent_lifetime']) ? sanitize_text_field(wp_unslash($raw['consent_lifetime'])) : $defaults['consent_lifetime'];
603 $policy_version = isset($raw['policy_version']) ? sanitize_text_field(wp_unslash($raw['policy_version'])) : $defaults['policy_version'];
604 $template = isset($raw['template']) ? sanitize_text_field(wp_unslash($raw['template'])) : $defaults['template'];
605
606 $content = $defaults['content'];
607 if (isset($raw['content']) && is_array($raw['content'])) {
608 $content = [
609 'title' => isset($raw['content']['title']) ? sanitize_text_field(wp_unslash($raw['content']['title'])) : $content['title'],
610 'message' => isset($raw['content']['message']) ? sanitize_textarea_field(wp_unslash($raw['content']['message'])) : $content['message'],
611 'privacy_label' => isset($raw['content']['privacy_label']) ? sanitize_text_field(wp_unslash($raw['content']['privacy_label'])) : $content['privacy_label'],
612 'privacy_url' => isset($raw['content']['privacy_url']) ? esc_url_raw(wp_unslash($raw['content']['privacy_url'])) : '',
613 'cookie_label' => isset($raw['content']['cookie_label']) ? sanitize_text_field(wp_unslash($raw['content']['cookie_label'])) : $content['cookie_label'],
614 'cookie_url' => isset($raw['content']['cookie_url']) ? esc_url_raw(wp_unslash($raw['content']['cookie_url'])) : '',
615 'cookie_url_custom' => isset($raw['content']['cookie_url_custom']) ? esc_url_raw(wp_unslash($raw['content']['cookie_url_custom'])) : '',
616 ];
617 }
618
619 $buttons = $defaults['buttons'];
620 if (isset($raw['buttons']) && is_array($raw['buttons'])) {
621 $buttons = [
622 'accept' => isset($raw['buttons']['accept']) ? sanitize_text_field(wp_unslash($raw['buttons']['accept'])) : $buttons['accept'],
623 'reject' => isset($raw['buttons']['reject']) ? sanitize_text_field(wp_unslash($raw['buttons']['reject'])) : $buttons['reject'],
624 'settings' => isset($raw['buttons']['settings']) ? sanitize_text_field(wp_unslash($raw['buttons']['settings'])) : $buttons['settings'],
625 'save' => isset($raw['buttons']['save']) ? sanitize_text_field(wp_unslash($raw['buttons']['save'])) : $buttons['save'],
626 ];
627 }
628
629 $categories = $this->sanitize_categories($raw, $defaults['categories']);
630
631 $design = $defaults['design'];
632 if (isset($raw['design']) && is_array($raw['design'])) {
633 $design['layout'] = isset($raw['design']['layout']) ? sanitize_text_field(wp_unslash($raw['design']['layout'])) : $design['layout'];
634 $design['preset'] = isset($raw['design']['preset']) ? sanitize_text_field(wp_unslash($raw['design']['preset'])) : $design['preset'];
635 $design['position'] = isset($raw['design']['position']) ? sanitize_text_field(wp_unslash($raw['design']['position'])) : $design['position'];
636 $design['width'] = isset($raw['design']['width']) ? sanitize_text_field(wp_unslash($raw['design']['width'])) : $design['width'];
637 $design['animation'] = isset($raw['design']['animation']) ? sanitize_text_field(wp_unslash($raw['design']['animation'])) : $design['animation'];
638 $design['shadow'] = isset($raw['design']['shadow']) ? (bool) $raw['design']['shadow'] : false;
639 $design['border_radius'] = isset($raw['design']['border_radius']) ? absint($raw['design']['border_radius']) : $design['border_radius'];
640
641 if (isset($raw['design']['colors']) && is_array($raw['design']['colors'])) {
642 foreach ($design['colors'] as $key => $color_default) {
643 if (isset($raw['design']['colors'][$key])) {
644 $design['colors'][$key] = sanitize_hex_color(wp_unslash($raw['design']['colors'][$key]));
645 }
646 }
647 }
648 }
649
650 $behavior = $defaults['behavior'];
651 if (isset($raw['behavior']) && is_array($raw['behavior'])) {
652 $behavior['show_on'] = isset($raw['behavior']['show_on']) ? sanitize_text_field(wp_unslash($raw['behavior']['show_on'])) : $behavior['show_on'];
653 $behavior['include_pages'] = isset($raw['behavior']['include_pages']) ? sanitize_textarea_field(wp_unslash($raw['behavior']['include_pages'])) : '';
654 $behavior['exclude_pages'] = isset($raw['behavior']['exclude_pages']) ? sanitize_textarea_field(wp_unslash($raw['behavior']['exclude_pages'])) : '';
655 $behavior['resurface'] = isset($raw['behavior']['resurface']) ? sanitize_text_field(wp_unslash($raw['behavior']['resurface'])) : $behavior['resurface'];
656 $behavior['scroll_consent'] = isset($raw['behavior']['scroll_consent']) ? (bool) $raw['behavior']['scroll_consent'] : false;
657 $behavior['click_consent'] = isset($raw['behavior']['click_consent']) ? (bool) $raw['behavior']['click_consent'] : false;
658 }
659
660 $scripts = $this->sanitize_script_rules($raw);
661
662 $manual_blocks = [];
663 if ($this->is_premium() && isset($raw['manual_blocks']) && is_array($raw['manual_blocks'])) {
664 foreach ($raw['manual_blocks'] as $block) {
665 if (!isset($block['code']) || trim($block['code']) === '') {
666 continue;
667 }
668 $manual_blocks[] = [
669 'name' => isset($block['name']) ? sanitize_text_field(wp_unslash($block['name'])) : '',
670 'category' => isset($block['category']) ? sanitize_text_field(wp_unslash($block['category'])) : 'analytics',
671 'type' => isset($block['type']) ? sanitize_text_field(wp_unslash($block['type'])) : 'inline-js',
672 'code' => wp_kses_post(wp_unslash($block['code'])),
673 ];
674 }
675 }
676
677 $advanced = $defaults['advanced'];
678 if (isset($raw['advanced']) && is_array($raw['advanced'])) {
679 $advanced['cookie_name'] = isset($raw['advanced']['cookie_name']) ? sanitize_key(wp_unslash($raw['advanced']['cookie_name'])) : $advanced['cookie_name'];
680 $advanced['cookie_path'] = isset($raw['advanced']['cookie_path']) ? sanitize_text_field(wp_unslash($raw['advanced']['cookie_path'])) : $advanced['cookie_path'];
681 $advanced['cookie_domain'] = isset($raw['advanced']['cookie_domain']) ? sanitize_text_field(wp_unslash($raw['advanced']['cookie_domain'])) : '';
682
683 $same_site = isset($raw['advanced']['same_site']) ? sanitize_text_field(wp_unslash($raw['advanced']['same_site'])) : $advanced['same_site'];
684 $same_site = ucfirst(strtolower($same_site));
685 if (!in_array($same_site, ['Lax', 'Strict', 'None'], true)) {
686 $same_site = $advanced['same_site'];
687 }
688 $advanced['same_site'] = $same_site;
689
690 $secure = isset($raw['advanced']['secure']) ? (bool) $raw['advanced']['secure'] : false;
691 if ($advanced['same_site'] === 'None') {
692 $secure = true;
693 }
694 $advanced['secure'] = $secure;
695
696 $storage = isset($raw['advanced']['storage']) ? sanitize_text_field(wp_unslash($raw['advanced']['storage'])) : $advanced['storage'];
697 $storage = strtolower($storage);
698 if (!in_array($storage, ['cookie', 'local'], true)) {
699 $storage = $advanced['storage'];
700 }
701 $advanced['storage'] = $storage;
702 }
703
704 $logs = $defaults['logs'];
705 if (isset($raw['logs']) && is_array($raw['logs'])) {
706 $logs['enabled'] = isset($raw['logs']['enabled']) ? (bool) $raw['logs']['enabled'] : false;
707 $logs['retention'] = isset($raw['logs']['retention']) ? sanitize_text_field(wp_unslash($raw['logs']['retention'])) : $logs['retention'];
708 }
709
710 $data_attribute_support = $this->is_premium() && isset($raw['data_attribute_support']) ? (bool) $raw['data_attribute_support'] : false;
711
712 return [
713 'enabled' => $enabled,
714 'mode' => $mode,
715 'region_targeting' => $region,
716 'custom_regions' => $custom_regions,
717 'consent_lifetime' => $consent_lifetime,
718 'policy_version' => $policy_version,
719 'template' => $template,
720 'content' => $content,
721 'buttons' => $buttons,
722 'categories' => $categories,
723 'design' => $design,
724 'behavior' => $behavior,
725 'scripts' => $scripts,
726 'manual_blocks' => $manual_blocks,
727 'data_attribute_support' => $data_attribute_support,
728 'advanced' => $advanced,
729 'logs' => $logs,
730 ];
731 }
732
733 /**
734 * Sanitizes categories including Pro-only additions.
735 *
736 * @param array<string, mixed> $raw Raw input.
737 * @param array<int, array<string, mixed>> $defaults Defaults.
738 * @return array<int, array<string, mixed>>
739 */
740 private function sanitize_categories(array $raw, array $defaults): array
741 {
742 $categories = $defaults;
743
744 if (isset($raw['categories']) && is_array($raw['categories'])) {
745 $categories = [];
746 foreach ($raw['categories'] as $category) {
747 if (!isset($category['key'])) {
748 continue;
749 }
750 $key = sanitize_key($category['key']);
751 $label = isset($category['label']) ? sanitize_text_field(wp_unslash($category['label'])) : '';
752 $description = isset($category['description']) ? sanitize_textarea_field(wp_unslash($category['description'])) : '';
753 $state = isset($category['state']) ? sanitize_text_field(wp_unslash($category['state'])) : 'off';
754 $display = isset($category['display']) ? (bool) $category['display'] : true;
755
756 if ($key === 'necessary') {
757 $state = 'required';
758 $display = true;
759 }
760
761 $categories[] = [
762 'key' => $key,
763 'label' => $label ?: ucfirst($key),
764 'description' => $description,
765 'state' => $state,
766 'display' => $display,
767 ];
768 }
769 }
770
771 if (!$this->is_premium()) {
772 $categories = array_slice($categories, 0, 4);
773 }
774
775 return $categories;
776 }
777
778 /**
779 * Sanitizes script blocking rules.
780 *
781 * @param array<string, mixed> $raw Raw input.
782 * @return array<string, array<string, string>>
783 */
784 private function sanitize_script_rules(array $raw): array
785 {
786 $rules = [];
787 if (isset($raw['scripts']) && is_array($raw['scripts'])) {
788 foreach ($raw['scripts'] as $rule) {
789 if (empty($rule['handle'])) {
790 continue;
791 }
792
793 $handle = sanitize_key(wp_unslash($rule['handle']));
794 $category = isset($rule['category']) ? sanitize_key(wp_unslash($rule['category'])) : 'analytics';
795 $mode = isset($rule['mode']) ? sanitize_text_field(wp_unslash($rule['mode'])) : 'block';
796
797 if (!in_array($mode, ['block', 'allow'], true)) {
798 $mode = 'block';
799 }
800
801 $rules[$handle] = [
802 'handle' => $handle,
803 'category' => $category,
804 'mode' => $mode,
805 ];
806 }
807 }
808
809 if (!$this->is_premium() && count($rules) > self::FREE_SCRIPT_LIMIT) {
810 $rules = array_slice($rules, 0, self::FREE_SCRIPT_LIMIT, true);
811 }
812
813 return $rules;
814 }
815
816 /**
817 * Determines if frontend should render.
818 *
819 * @return bool
820 */
821 private function should_render(): bool
822 {
823 if (is_admin()) {
824 return false;
825 }
826
827 if (!$this->options['enabled']) {
828 return false;
829 }
830
831 if (!$this->passes_page_rules()) {
832 return false;
833 }
834
835 if (!$this->passes_region_rules()) {
836 return false;
837 }
838
839 return true;
840 }
841
842 /**
843 * Checks page targeting rules.
844 *
845 * @return bool
846 */
847 private function passes_page_rules(): bool
848 {
849 $behavior = $this->options['behavior'];
850
851 if (!$this->is_premium()) {
852 return true;
853 }
854
855 if ($behavior['show_on'] === 'all') {
856 return true;
857 }
858
859 if ($behavior['show_on'] === 'include') {
860 $ids = array_filter(array_map('absint', explode(',', $behavior['include_pages'])));
861 return is_page($ids);
862 }
863
864 if ($behavior['show_on'] === 'exclude') {
865 $ids = array_filter(array_map('absint', explode(',', $behavior['exclude_pages'])));
866 return !is_page($ids);
867 }
868
869 return true;
870 }
871
872 /**
873 * Checks region targeting rules.
874 *
875 * @return bool
876 */
877 private function passes_region_rules(): bool
878 {
879 $target = $this->options['region_targeting'];
880
881 if ($target === 'all') {
882 return true;
883 }
884
885 $region = $this->resolve_region();
886
887 // Fail-open for privacy compliance: if we cannot resolve region reliably, show the banner.
888 if ($region === 'unknown') {
889 return true;
890 }
891
892 if ($target === 'eu') {
893 return $region === 'eu';
894 }
895
896 if ($target === 'us') {
897 return $region === 'us';
898 }
899
900 if ($target === 'custom' && $this->is_premium()) {
901 $raw = array_filter(array_map('trim', explode(',', (string) $this->options['custom_regions'])));
902 $countries = [];
903 foreach ($raw as $code) {
904 $code = (string) $code;
905 $code_lower = strtolower($code);
906 if ($code_lower === 'eu' || $code_lower === 'us') {
907 $countries[] = $code_lower;
908 continue;
909 }
910 $countries[] = strtoupper($code);
911 }
912
913 $region_norm = $region;
914 if ($region !== 'eu' && $region !== 'us') {
915 $region_norm = strtoupper($region);
916 }
917
918 return in_array($region_norm, $countries, true);
919 }
920
921 return true;
922 }
923
924 /**
925 * Gets consent data from cookie.
926 *
927 * @return array<string, mixed>
928 */
929 private function get_consent_cookie(): array
930 {
931 $name = $this->options['advanced']['cookie_name'];
932 if (!isset($_COOKIE[$name])) {
933 return [];
934 }
935
936 $decoded = json_decode(stripslashes($_COOKIE[$name]), true);
937 if (!is_array($decoded)) {
938 return [];
939 }
940
941 return $decoded;
942 }
943
944 /**
945 * Determines if a category has consent.
946 *
947 * @param string $category Category key.
948 * @return bool
949 */
950 private function has_consent_for_category(string $category): bool
951 {
952 if ($category === 'necessary') {
953 return true;
954 }
955
956 $consent = $this->get_consent_cookie();
957 if (!isset($consent['categories']) || !is_array($consent['categories'])) {
958 return false;
959 }
960
961 return in_array($category, $consent['categories'], true);
962 }
963
964 /**
965 * Builds frontend payload for JS.
966 *
967 * @return array<string, mixed>
968 */
969 private function get_frontend_payload(): array
970 {
971 $categories = $this->options['categories'];
972 if (!$this->is_premium()) {
973 $categories = array_slice($categories, 0, 4);
974 }
975
976 $region = 'all';
977 if (($this->options['region_targeting'] ?? 'all') !== 'all') {
978 $region = $this->resolve_region();
979 }
980
981 return [
982 'options' => [
983 'mode' => $this->options['mode'],
984 'template' => $this->options['template'],
985 'content' => $this->options['content'],
986 'buttons' => $this->options['buttons'],
987 'categories' => $categories,
988 'design' => $this->options['design'],
989 'behavior' => $this->options['behavior'],
990 'manualBlocks' => $this->is_premium() ? $this->options['manual_blocks'] : [],
991 'scripts' => array_values($this->get_script_rules()),
992 'dataAttributes' => $this->options['data_attribute_support'],
993 'advanced' => $this->options['advanced'],
994 'policyVersion' => $this->options['policy_version'],
995 'consentLifetime' => $this->options['consent_lifetime'],
996 'isPremium' => $this->is_premium(),
997 'logsEnabled' => $this->is_premium() && $this->options['logs']['enabled'],
998 ],
999 'ajax' => [
1000 'url' => admin_url('admin-ajax.php'),
1001 'nonce' => wp_create_nonce('king_addons_cookie_log'),
1002 ],
1003 'region' => $region,
1004 ];
1005 }
1006
1007 /**
1008 * Returns script blocking rules keyed by handle.
1009 *
1010 * @return array<string, array<string, string>>
1011 */
1012 private function get_script_rules(): array
1013 {
1014 return $this->options['scripts'];
1015 }
1016
1017 /**
1018 * Determines visitor region with caching and filter hook.
1019 *
1020 * @return string
1021 */
1022 private function resolve_region(): string
1023 {
1024 $cache_key = $this->get_geo_cache_key();
1025 $cached = get_transient($cache_key);
1026 if ($cached) {
1027 return (string) $cached;
1028 }
1029
1030 $region = 'unknown';
1031 $response = wp_remote_get(
1032 'https://ipapi.co/json/',
1033 [
1034 'timeout' => 2,
1035 'redirection' => 2,
1036 'user-agent' => 'KingAddonsCookieConsent/' . (defined('KING_ADDONS_VERSION') ? KING_ADDONS_VERSION : 'unknown'),
1037 ]
1038 );
1039 if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
1040 $data = json_decode(wp_remote_retrieve_body($response), true);
1041 if (isset($data['country_code'])) {
1042 $country = strtoupper(sanitize_text_field($data['country_code']));
1043 $eu_countries = $this->get_eu_countries();
1044 if (in_array($country, $eu_countries, true)) {
1045 $region = 'eu';
1046 } elseif ($country === 'US') {
1047 $region = 'us';
1048 } else {
1049 $region = $country;
1050 }
1051 }
1052 }
1053
1054 $region = apply_filters('king_addons_cookie_consent_region', $region);
1055 set_transient($cache_key, $region, self::GEO_TTL);
1056
1057 return $region;
1058 }
1059
1060 /**
1061 * Inserts a log row.
1062 *
1063 * @param string $action Action name.
1064 * @param array<int, string> $categories Consent categories.
1065 * @param string $region Region.
1066 * @param string $device Device descriptor.
1067 * @return void
1068 */
1069 private function insert_log_row(string $action, array $categories, string $region, string $device): void
1070 {
1071 global $wpdb;
1072 $table = $wpdb->prefix . self::LOG_TABLE;
1073
1074 $wpdb->insert(
1075 $table,
1076 [
1077 'action' => $action,
1078 'categories' => wp_json_encode($categories),
1079 'region' => $region,
1080 'device' => $device,
1081 'logged_at' => current_time('mysql', true),
1082 ],
1083 ['%s', '%s', '%s', '%s', '%s']
1084 );
1085 }
1086
1087 /**
1088 * Creates the log table if missing.
1089 *
1090 * @return void
1091 */
1092 private function maybe_create_log_table(): void
1093 {
1094 global $wpdb;
1095 $table = $wpdb->prefix . self::LOG_TABLE;
1096
1097 $charset = $wpdb->get_charset_collate();
1098 $sql = "CREATE TABLE {$table} (
1099 id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
1100 action varchar(50) NOT NULL,
1101 categories text NULL,
1102 region varchar(20) NULL,
1103 device varchar(50) NULL,
1104 logged_at datetime NOT NULL,
1105 PRIMARY KEY (id)
1106 ) {$charset};";
1107
1108 require_once ABSPATH . 'wp-admin/includes/upgrade.php';
1109 dbDelta($sql);
1110 }
1111
1112 /**
1113 * Returns a list of EU country codes.
1114 *
1115 * @return array<int, string>
1116 */
1117 private function get_eu_countries(): array
1118 {
1119 return [
1120 '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',
1121 ];
1122 }
1123
1124 /**
1125 * Builds a geo cache key scoped per IP.
1126 *
1127 * @return string
1128 */
1129 private function get_geo_cache_key(): string
1130 {
1131 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown';
1132 return self::GEO_TRANSIENT . '_' . md5($ip);
1133 }
1134
1135 /**
1136 * Indicates whether Pro is active.
1137 *
1138 * @return bool
1139 */
1140 private function is_premium(): bool
1141 {
1142 if (!function_exists('king_addons_freemius')) {
1143 return false;
1144 }
1145
1146 $fs = king_addons_freemius();
1147 if (!is_object($fs) || !method_exists($fs, 'can_use_premium_code__premium_only')) {
1148 return false;
1149 }
1150
1151 return $fs->can_use_premium_code__premium_only();
1152 }
1153 }
1154
1155
1156
1157