*/ protected array $options = []; /** * Singleton accessor that swaps in the Pro implementation when available. * * @return Age_Gate */ public static function instance(): Age_Gate { if (is_null(self::$instance)) { if (class_exists('\King_Addons\Age_Gate_Pro') && king_addons_freemius()->can_use_premium_code()) { self::$instance = new Age_Gate_Pro(); } else { self::$instance = new self(); } } return self::$instance; } /** * Bootstraps the extension. */ public function __construct() { $this->options = $this->get_options(); register_activation_hook(KING_ADDONS_PATH . 'king-addons.php', [$this, 'handle_activation']); add_action('admin_init', [$this, 'register_settings']); add_action('admin_enqueue_scripts', [$this, 'enqueue_admin_assets']); add_action('wp_enqueue_scripts', [$this, 'enqueue_front_assets']); add_action('wp_footer', [$this, 'render_frontend_markup']); add_action('wp_body_open', [$this, 'render_portal_container']); add_action('template_redirect', [$this, 'maybe_handle_denied_redirect']); // AJAX handler for DOB validation (Pro) add_action('wp_ajax_' . self::AJAX_ACTION_DOB, [$this, 'ajax_validate_dob']); add_action('wp_ajax_nopriv_' . self::AJAX_ACTION_DOB, [$this, 'ajax_validate_dob']); } /** * AJAX handler for date of birth validation. * * @return void */ public function ajax_validate_dob(): void { check_ajax_referer(self::NONCE_ACTION, 'nonce'); if (!$this->is_premium()) { wp_send_json_error(['message' => esc_html__('Pro feature required.', 'king-addons')]); return; } $day = isset($_POST['day']) ? absint($_POST['day']) : 0; $month = isset($_POST['month']) ? absint($_POST['month']) : 0; $year = isset($_POST['year']) ? absint($_POST['year']) : 0; $dob_options = $this->options['dob']; $min_age = (int) $this->options['general']['min_age']; $max_age = (int) $dob_options['max_age']; // Validate date components if ($day < 1 || $day > 31 || $month < 1 || $month > 12 || $year < 1900) { wp_send_json_error(['message' => $dob_options['error_invalid']]); return; } // Check if date is valid if (!checkdate($month, $day, $year)) { wp_send_json_error(['message' => $dob_options['error_invalid']]); return; } // Calculate age $dob = new \DateTime(sprintf('%04d-%02d-%02d', $year, $month, $day)); $now = new \DateTime(); $age = $now->diff($dob)->y; // Validate age range if ($age < 0 || $age > $max_age) { wp_send_json_error(['message' => $dob_options['error_invalid']]); return; } // Check minimum age requirement $required_age = $this->resolve_minimum_age(); if ($age < $required_age) { wp_send_json_error(['message' => $dob_options['error_denied']]); return; } // Set cookie server-side for extra security $cookie_days = (int) $this->options['general']['cookie_days']; $this->set_status_cookie('allowed', $cookie_days); wp_send_json_success(['message' => esc_html__('Age verified.', 'king-addons')]); } /** * Resolves minimum age considering geo rules. * * @return int */ protected function resolve_minimum_age(): int { $base_age = (int) $this->options['general']['min_age']; if (!$this->is_premium() || empty($this->options['geo']['enabled'])) { return $base_age; } $geo_map = $this->options['geo']['map']; $default_age = (int) $this->options['geo']['default_age']; $country = $this->detect_country(); if ($country && isset($geo_map[$country])) { return (int) $geo_map[$country]; } return $default_age ?: $base_age; } /** * Detects visitor country using WooCommerce geolocation if available. * * @return string Country code or empty string. */ protected function detect_country(): string { // Try WooCommerce geolocation first if (class_exists('WC_Geolocation')) { $geo = \WC_Geolocation::geolocate_ip(); if (!empty($geo['country'])) { return strtoupper($geo['country']); } } return ''; } /** * Creates default options on activation. * * @return void */ public function handle_activation(): void { if (!get_option(self::OPTION_NAME)) { add_option(self::OPTION_NAME, $this->get_default_options()); } } /** * Registers the settings entry and sanitize callback. * * @return void */ public function register_settings(): void { register_setting('king_addons_age_gate', self::OPTION_NAME, [ 'type' => 'array', 'sanitize_callback' => [$this, 'sanitize_options'], ]); } /** * Renders the admin settings page. * * @return void */ public function render_admin_page(): void { if (!current_user_can('manage_options')) { return; } $this->options = $this->get_options(); $options = $this->options; $is_premium = $this->is_premium(); settings_errors('king_addons_age_gate'); include __DIR__ . '/templates/admin-page.php'; } /** * Enqueues admin assets for the Age Gate page. * * @param string $hook Current admin hook. * @return void */ public function enqueue_admin_assets(string $hook): void { if ($hook !== 'king-addons_page_king-addons-age-gate') { return; } wp_enqueue_style( 'king-addons-age-gate-admin', KING_ADDONS_URL . 'includes/extensions/Age_Gate/assets/admin.css', [], KING_ADDONS_VERSION ); wp_enqueue_script( 'king-addons-age-gate-admin', KING_ADDONS_URL . 'includes/extensions/Age_Gate/assets/admin.js', ['jquery'], KING_ADDONS_VERSION, true ); } /** * Registers and enqueues frontend assets when needed. * * @return void */ public function enqueue_front_assets(): void { if (defined('KING_ADDONS_IS_MAINTENANCE_PAGE') && KING_ADDONS_IS_MAINTENANCE_PAGE) { return; } if (is_admin()) { return; } $should_render = $this->should_render() || $this->should_render_block_state() || $this->is_preview_mode(); if (!$should_render) { return; } wp_register_style( 'king-addons-age-gate', KING_ADDONS_URL . 'includes/widgets/Age_Gate/style.css', [], KING_ADDONS_VERSION ); wp_register_script( 'king-addons-age-gate', KING_ADDONS_URL . 'includes/widgets/Age_Gate/script.js', [], KING_ADDONS_VERSION, true ); wp_localize_script('king-addons-age-gate', 'kingAddonsAgeGate', $this->get_frontend_payload()); wp_enqueue_style('king-addons-age-gate'); wp_enqueue_script('king-addons-age-gate'); } /** * Outputs the overlay container markup. * * @return void */ public function render_frontend_markup(): void { if (defined('KING_ADDONS_IS_MAINTENANCE_PAGE') && KING_ADDONS_IS_MAINTENANCE_PAGE) { return; } if (!$this->should_render() && !$this->should_render_block_state() && !$this->is_preview_mode()) { return; } ?> should_render() && !$this->should_render_block_state() && !$this->is_preview_mode()) { return; } echo ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } /** * Performs redirect on denied state when configured. * * @return void */ public function maybe_handle_denied_redirect(): void { if (defined('KING_ADDONS_IS_MAINTENANCE_PAGE') && KING_ADDONS_IS_MAINTENANCE_PAGE) { return; } if (is_admin() || wp_doing_ajax()) { return; } if (!$this->is_enabled()) { return; } $status = $this->get_cookie_status(); $behaviour = $this->options['behaviour']; if ($status !== 'denied' || !$this->is_deny_action_redirect($behaviour['deny_action'] ?? '')) { return; } $deny_action = (string) ($behaviour['deny_action'] ?? ''); if ($deny_action === 'redirect_page' || $deny_action === 'redirect') { $redirect_id = isset($behaviour['deny_redirect_page']) ? (int) $behaviour['deny_redirect_page'] : 0; if ($redirect_id && is_page($redirect_id)) { return; } } $url = $this->resolve_deny_redirect_url(); if (!$url) { return; } $request_uri = isset($_SERVER['REQUEST_URI']) ? (string) wp_unslash($_SERVER['REQUEST_URI']) : ''; if ($request_uri) { $current_url = home_url($request_uri); if (untrailingslashit($current_url) === untrailingslashit($url)) { return; } } $site_host = wp_parse_url(home_url(), PHP_URL_HOST); $target_host = wp_parse_url($url, PHP_URL_HOST); // Prefer safe redirects for internal destinations; allow external URLs when configured by admin. if ($site_host && $target_host && strtolower((string) $site_host) === strtolower((string) $target_host)) { wp_safe_redirect($url); } else { wp_redirect($url); } exit; } /** * Determines whether the overlay should render. * * @return bool */ public function should_render(): bool { if (defined('KING_ADDONS_IS_MAINTENANCE_PAGE') && KING_ADDONS_IS_MAINTENANCE_PAGE) { return false; } if (is_admin() || wp_doing_ajax()) { return false; } if (!$this->is_enabled()) { return false; } if ($this->is_preview_mode()) { return true; } if (!$this->passes_display_rules()) { return false; } if ($this->is_excluded_page()) { return false; } $status = $this->get_cookie_status(); if ($status === 'allowed') { return false; } if ($status === 'denied' && $this->is_deny_action_redirect($this->options['behaviour']['deny_action'] ?? '') && $this->has_deny_redirect_target()) { return false; } return true; } /** * Indicates that a blocked state still needs markup (deny blocking). * * @return bool */ protected function should_render_block_state(): bool { if (defined('KING_ADDONS_IS_MAINTENANCE_PAGE') && KING_ADDONS_IS_MAINTENANCE_PAGE) { return false; } if (!$this->is_enabled()) { return false; } $status = $this->get_cookie_status(); return $status === 'denied' && (($this->options['behaviour']['deny_action'] ?? '') === 'block'); } /** * Checks if general display rules allow the gate. * * @return bool */ protected function passes_display_rules(): bool { $general = $this->options['general']; $display = $this->options['display']; if ($general['audience'] === 'guests' && is_user_logged_in()) { return false; } $scope = $display['scope']; if ($scope === 'posts') { return is_singular('post'); } if ($scope === 'pages') { return $this->is_pages_scope_match(); } return true; } /** * Pages scope includes WordPress pages and the WooCommerce shop archive. * * WooCommerce shop is a product archive, so is_page() is false there even * when the Shop page ID is selected as a page. */ protected function is_pages_scope_match(): bool { if (is_page()) { return true; } return function_exists('is_shop') && is_shop(); } /** * Checks if current page is excluded from gating. * * @return bool */ protected function is_excluded_page(): bool { $exclude_ids = array_map('absint', $this->options['display']['exclude_ids']); if (function_exists('is_shop') && is_shop() && function_exists('wc_get_page_id')) { $shop_id = (int) wc_get_page_id('shop'); if ($shop_id && in_array($shop_id, $exclude_ids, true)) { return true; } } if (!is_singular()) { return false; } $current_id = get_the_ID(); if (in_array($current_id, $exclude_ids, true)) { return true; } $deny_redirect_id = isset($this->options['behaviour']['deny_redirect_page']) ? (int)$this->options['behaviour']['deny_redirect_page'] : 0; if ($deny_redirect_id && $deny_redirect_id === $current_id) { return true; } return false; } /** * Builds the frontend payload localized to JS. * * @return array */ public function get_frontend_payload(): array { $design = $this->options['design']; $behaviour = $this->options['behaviour']; $general = $this->options['general']; $dob = $this->options['dob']; $deny_redirect_id = isset($behaviour['deny_redirect_page']) ? (int) $behaviour['deny_redirect_page'] : 0; $deny_redirect_url = $this->resolve_deny_redirect_url(); $deny_action_raw = (string) ($behaviour['deny_action'] ?? 'block'); $deny_action = $this->is_deny_action_redirect($deny_action_raw) ? 'redirect' : 'block'; if ($deny_action === 'redirect' && !$deny_redirect_url) { $deny_action = 'block'; } return [ 'enabled' => $this->is_enabled(), 'mode' => $general['mode'], 'minAge' => $this->resolve_minimum_age(), 'texts' => [ 'title' => $design['title'], 'subtitle' => $design['subtitle'], 'yes' => $design['button_yes'], 'no' => $design['button_no'], 'block' => $behaviour['block_message'], ], 'design' => [ 'template' => $design['template'], 'overlayColor' => $design['overlay_color'], 'overlayOpacity' => $design['overlay_opacity'], 'cardBackground' => $design['card_background'], 'cardWidth' => $design['card_width'], 'cardAlign' => $design['card_align'], 'textColor' => $design['text_color'], 'titleSize' => $design['title_size'], 'bodySize' => $design['body_size'], 'titleWeight' => $design['title_weight'], 'bodyWeight' => $design['body_weight'], 'buttonYesColor' => $design['button_yes_color'], 'buttonYesBg' => $design['button_yes_bg'], 'buttonNoColor' => $design['button_no_color'], 'buttonNoBg' => $design['button_no_bg'], 'buttonYesHoverBg' => $design['button_yes_hover_bg'] ?? $design['button_yes_bg'], 'buttonYesHoverColor' => $design['button_yes_hover_color'] ?? $design['button_yes_color'], 'buttonNoHoverBg' => $design['button_no_hover_bg'] ?? $design['button_no_bg'], 'buttonNoHoverColor' => $design['button_no_hover_color'] ?? $design['button_no_color'], 'animation' => $design['animation'], 'logo' => $design['logo'], 'backgroundImage' => $design['background_image'], ], 'behaviour' => [ 'denyAction' => $deny_action, 'denyRedirect' => $deny_redirect_id, 'denyRedirectUrl' => $deny_redirect_url, 'blockMessage' => $behaviour['block_message'], 'consentCheckbox' => (bool) $behaviour['consent_checkbox'], 'consentLabel' => __('I agree to the policy.', 'king-addons'), 'repeatMode' => $behaviour['repeat_mode'] ?? 'days', 'repeatDays' => (int) ($behaviour['repeat_days'] ?? 30), ], 'cookie' => [ 'name' => $this->get_cookie_name(), 'days' => (int) $general['cookie_days'], 'revision' => $this->options['advanced']['revision'], 'respectRevision' => (bool) $behaviour['reset_on_rule_change'], 'domain' => $this->get_cookie_domain(), ], 'dob' => [ 'format' => $dob['format'], 'errors' => [ 'invalid' => $dob['error_invalid'], 'denied' => $dob['error_denied'], ], ], 'status' => $this->get_cookie_status(), 'shouldRender' => $this->should_render() || $this->is_preview_mode(), 'isPreview' => $this->is_preview_mode(), 'ajax' => [ 'url' => admin_url('admin-ajax.php'), 'nonce' => wp_create_nonce(self::NONCE_ACTION), 'action' => self::AJAX_ACTION_DOB, ], 'isPremium' => $this->is_premium(), 'elementorTemplate' => '', ]; } /** * Preview mode: force render Age Gate for admins via query param. * Example: /?ka_age_gate_preview=1 */ protected function is_preview_mode(): bool { if (is_admin() || wp_doing_ajax()) { return false; } if (!$this->is_enabled()) { return false; } if (!is_user_logged_in() || !current_user_can('manage_options')) { return false; } return isset($_GET['ka_age_gate_preview']) && (string) $_GET['ka_age_gate_preview'] === '1'; } /** * Indicates whether premium code is available. * * @return bool */ protected function is_premium(): bool { if (!function_exists('king_addons_freemius')) { return false; } $fs = king_addons_freemius(); if (!is_object($fs) || !method_exists($fs, 'can_use_premium_code')) { return false; } return (bool) $fs->can_use_premium_code(); } /** * Returns the sanitized options merged with defaults. * * @return array */ public function get_options(): array { if (class_exists('King_Addons\\Text_Entities_Migration')) { Text_Entities_Migration::maybe_run(); } $saved = get_option(self::OPTION_NAME, []); $defaults = $this->get_default_options(); return wp_parse_args($saved, $defaults); } /** * Default options for the feature. * * @return array */ public function get_default_options(): array { return [ 'general' => [ 'enabled' => false, 'audience' => 'guests', 'mode' => 'confirm', 'min_age' => 18, 'cookie_days' => 30, ], 'display' => [ 'scope' => 'site', 'exclude_ids' => [], 'mode' => 'global', 'cpt_scope' => [], 'archives' => false, 'woo' => [ 'enabled' => false, 'apply_to' => 'product', 'categories' => [], ], ], 'design' => [ 'template' => 'center-card', 'overlay_color' => '#0d0d0d', 'overlay_opacity' => 0.7, 'card_background' => '#ffffff', 'card_width' => 520, 'card_align' => 'center', 'title' => __('Age verification required', 'king-addons'), 'subtitle' => __('This content is restricted to visitors over the specified age.', 'king-addons'), 'button_yes' => __('Yes, continue', 'king-addons'), 'button_no' => __('No, leave', 'king-addons'), 'text_color' => '#111827', 'title_size' => 24, 'body_size' => 16, 'title_weight' => 700, 'body_weight' => 400, 'button_yes_color' => '#ffffff', 'button_yes_bg' => '#10b981', 'button_no_color' => '#ffffff', 'button_no_bg' => '#ef4444', 'animation' => 'none', 'logo' => '', 'background_image' => '', 'button_yes_hover_bg' => '#0f9c75', 'button_no_hover_bg' => '#d92d20', 'button_yes_hover_color' => '#ffffff', 'button_no_hover_color' => '#ffffff', 'elementor_template' => 0, ], 'behaviour' => [ 'deny_action' => 'redirect_url', 'deny_redirect_page' => 0, 'deny_redirect_url' => 'https://google.com', 'block_message' => __('Access denied. You do not meet the minimum age requirement.', 'king-addons'), 'consent_checkbox' => false, 'reset_on_rule_change' => true, 'repeat_mode' => 'days', 'repeat_days' => 30, ], 'advanced' => [ 'revision' => time(), ], 'geo' => [ 'enabled' => false, 'default_age' => 18, 'map' => [], ], 'dob' => [ 'format' => 'dmy', 'max_age' => 120, 'error_invalid' => __('Enter a valid date of birth.', 'king-addons'), 'error_denied' => __('You do not meet the minimum age requirement.', 'king-addons'), ], ]; } /** * Sanitizes and normalizes saved options. * * @param mixed $raw Raw submitted options. * @return array */ public function sanitize_options($raw): array { $raw = is_array($raw) ? $raw : []; $current = $this->get_options(); $defaults = $this->get_default_options(); $general = $raw['general'] ?? []; $display = $raw['display'] ?? []; $design = $raw['design'] ?? []; $behaviour = $raw['behaviour'] ?? []; $allowed_templates = [ 'center-card', 'bottom-card', 'top-card', 'side-left', 'side-right', 'fullscreen', ]; $template = in_array($design['template'] ?? 'center-card', $allowed_templates, true) ? $design['template'] : 'center-card'; // Back-compat: if card_align isn't explicitly set, infer it from template. $inferred_align = 'center'; if ($template === 'bottom-card') { $inferred_align = 'bottom'; } elseif ($template === 'top-card') { $inferred_align = 'top'; } $deny_action_raw = (string) ($behaviour['deny_action'] ?? ($defaults['behaviour']['deny_action'] ?? 'redirect_url')); if ($deny_action_raw === 'redirect') { // Back-compat: older saved values. $deny_action_raw = 'redirect_page'; } $allowed_deny_actions = ['redirect_page', 'redirect_url', 'block']; $deny_action = in_array($deny_action_raw, $allowed_deny_actions, true) ? $deny_action_raw : ($defaults['behaviour']['deny_action'] ?? 'redirect_url'); $sanitized = [ 'general' => [ 'enabled' => !empty($general['enabled']), 'audience' => in_array($general['audience'] ?? 'guests', ['guests', 'all'], true) ? $general['audience'] : $defaults['general']['audience'], 'mode' => in_array($general['mode'] ?? 'confirm', ['confirm', 'minimum'], true) ? $general['mode'] : 'confirm', 'min_age' => max(0, absint($general['min_age'] ?? $defaults['general']['min_age'])), 'cookie_days' => max(0, absint($general['cookie_days'] ?? $defaults['general']['cookie_days'])), ], 'display' => [ 'scope' => in_array($display['scope'] ?? 'site', ['site', 'posts', 'pages'], true) ? $display['scope'] : $defaults['display']['scope'], 'exclude_ids' => array_values(array_filter(array_map('absint', $display['exclude_ids'] ?? []))), ], 'design' => [ 'template' => $template, 'overlay_color' => sanitize_hex_color($design['overlay_color'] ?? $defaults['design']['overlay_color']) ?: $defaults['design']['overlay_color'], 'overlay_opacity' => min(1, max(0, floatval($design['overlay_opacity'] ?? $defaults['design']['overlay_opacity']))), 'card_background' => sanitize_hex_color($design['card_background'] ?? $defaults['design']['card_background']) ?: $defaults['design']['card_background'], 'card_width' => max(280, absint($design['card_width'] ?? $defaults['design']['card_width'])), 'card_align' => in_array($design['card_align'] ?? $inferred_align, ['center', 'bottom', 'top'], true) ? ($design['card_align'] ?? $inferred_align) : 'center', 'title' => sanitize_text_field($design['title'] ?? $defaults['design']['title']), 'subtitle' => sanitize_text_field($design['subtitle'] ?? $defaults['design']['subtitle']), 'button_yes' => sanitize_text_field($design['button_yes'] ?? $defaults['design']['button_yes']), 'button_no' => sanitize_text_field($design['button_no'] ?? $defaults['design']['button_no']), 'text_color' => sanitize_hex_color($design['text_color'] ?? $defaults['design']['text_color']) ?: $defaults['design']['text_color'], 'title_size' => max(10, absint($design['title_size'] ?? $defaults['design']['title_size'])), 'body_size' => max(10, absint($design['body_size'] ?? $defaults['design']['body_size'])), 'title_weight' => max(100, absint($design['title_weight'] ?? $defaults['design']['title_weight'])), 'body_weight' => max(100, absint($design['body_weight'] ?? $defaults['design']['body_weight'])), 'button_yes_color' => sanitize_hex_color($design['button_yes_color'] ?? $defaults['design']['button_yes_color']) ?: $defaults['design']['button_yes_color'], 'button_yes_bg' => sanitize_hex_color($design['button_yes_bg'] ?? $defaults['design']['button_yes_bg']) ?: $defaults['design']['button_yes_bg'], 'button_no_color' => sanitize_hex_color($design['button_no_color'] ?? $defaults['design']['button_no_color']) ?: $defaults['design']['button_no_color'], 'button_no_bg' => sanitize_hex_color($design['button_no_bg'] ?? $defaults['design']['button_no_bg']) ?: $defaults['design']['button_no_bg'], 'animation' => in_array($design['animation'] ?? 'none', ['none', 'fade', 'slide-up', 'slide-down'], true) ? $design['animation'] : 'none', 'logo' => esc_url_raw($design['logo'] ?? ''), 'background_image' => esc_url_raw($design['background_image'] ?? ''), ], 'behaviour' => [ 'deny_action' => $deny_action, 'deny_redirect_page' => absint($behaviour['deny_redirect_page'] ?? 0), 'deny_redirect_url' => $this->sanitize_redirect_url($behaviour['deny_redirect_url'] ?? ($defaults['behaviour']['deny_redirect_url'] ?? '')), 'block_message' => sanitize_text_field($behaviour['block_message'] ?? $defaults['behaviour']['block_message']), 'consent_checkbox' => !empty($behaviour['consent_checkbox']), 'reset_on_rule_change' => isset($behaviour['reset_on_rule_change']) ? (bool) $behaviour['reset_on_rule_change'] : $defaults['behaviour']['reset_on_rule_change'], 'repeat_mode' => in_array($behaviour['repeat_mode'] ?? 'days', ['days', 'session', 'once'], true) ? $behaviour['repeat_mode'] : 'days', 'repeat_days' => max(0, absint($behaviour['repeat_days'] ?? $defaults['behaviour']['repeat_days'])), ], 'advanced' => [ 'revision' => $current['advanced']['revision'] ?? time(), ], 'geo' => $this->sanitize_geo_options($raw), 'dob' => $this->sanitize_dob_options($raw), ]; // Force revision bump when rule reset is enabled and settings are updated. if (!empty($sanitized['behaviour']['reset_on_rule_change'])) { $sanitized['advanced']['revision'] = time(); } $this->options = wp_parse_args($sanitized, $this->get_default_options()); return $this->options; } /** * Sanitizes geo options. * * @param array $raw Raw input. * @return array */ protected function sanitize_geo_options(array $raw): array { $defaults = $this->get_default_options()['geo']; $geo = $raw['geo'] ?? []; $enabled = !empty($geo['enabled']) && $this->is_premium(); $default_age = max(0, absint($geo['default_age'] ?? $defaults['default_age'])); // Parse geo map from textarea (format: "US=21\nUK=18") $map = []; if (!empty($geo['map']) && is_string($geo['map'])) { $lines = explode("\n", $geo['map']); foreach ($lines as $line) { $line = trim($line); if (empty($line) || strpos($line, '=') === false) { continue; } [$code, $age] = explode('=', $line, 2); $code = strtoupper(trim(sanitize_text_field($code))); $age = absint(trim($age)); if (strlen($code) === 2 && $age > 0) { $map[$code] = $age; } } } elseif (is_array($geo['map'] ?? null)) { // Already an array (from existing options) foreach ($geo['map'] as $code => $age) { $code = strtoupper(sanitize_text_field($code)); $map[$code] = absint($age); } } return [ 'enabled' => $enabled, 'default_age' => $default_age, 'map' => $map, ]; } /** * Sanitizes DOB options. * * @param array $raw Raw input. * @return array */ protected function sanitize_dob_options(array $raw): array { $defaults = $this->get_default_options()['dob']; $dob = $raw['dob'] ?? []; return [ 'format' => in_array($dob['format'] ?? 'dmy', ['dmy', 'mdy', 'ymd'], true) ? $dob['format'] : 'dmy', 'max_age' => max(10, absint($dob['max_age'] ?? $defaults['max_age'])), 'error_invalid' => sanitize_text_field($dob['error_invalid'] ?? $defaults['error_invalid']), 'error_denied' => sanitize_text_field($dob['error_denied'] ?? $defaults['error_denied']), ]; } /** * Sanitizes a custom redirect URL. * * @param mixed $raw_url Raw URL. * @return string Sanitized URL or empty string. */ protected function sanitize_redirect_url($raw_url): string { $url = is_string($raw_url) ? trim($raw_url) : ''; if ($url === '') { return ''; } $url = esc_url_raw($url); if ($url && wp_http_validate_url($url)) { return $url; } return ''; } /** * Returns the effective redirect destination when denial action is redirect. * Prefers an internal selected page, otherwise falls back to the custom URL. */ protected function resolve_deny_redirect_url(): string { $behaviour = $this->options['behaviour'] ?? []; $deny_action = (string) ($behaviour['deny_action'] ?? ''); // Legacy back-compat. if ($deny_action === 'redirect') { $deny_action = 'redirect_page'; } $custom_url = isset($behaviour['deny_redirect_url']) ? $this->sanitize_redirect_url($behaviour['deny_redirect_url']) : ''; if ($deny_action === 'redirect_url') { return $custom_url; } // redirect_page (default): prefer page ID, but fall back to URL if provided. $redirect_id = isset($behaviour['deny_redirect_page']) ? (int) $behaviour['deny_redirect_page'] : 0; if ($redirect_id) { return get_permalink($redirect_id) ?: ''; } return $custom_url; } /** * True for redirect-based deny actions. */ protected function is_deny_action_redirect(string $deny_action): bool { return in_array($deny_action, ['redirect', 'redirect_page', 'redirect_url'], true); } /** * Whether a denial redirect destination is configured. */ protected function has_deny_redirect_target(): bool { return $this->resolve_deny_redirect_url() !== ''; } /** * Determines whether the feature is active. * * @return bool */ protected function is_enabled(): bool { return !empty($this->options['general']['enabled']); } /** * Returns the cookie name to use. * * @return string */ protected function get_cookie_name(): string { return self::COOKIE_NAME; } /** * Parses the stored cookie status. * * @return string allowed|denied|dob:{date}|'' */ protected function get_cookie_status(): string { $cookie_name = $this->get_cookie_name(); if (!isset($_COOKIE[$cookie_name])) { return ''; } $raw = sanitize_text_field(wp_unslash($_COOKIE[$cookie_name])); $parts = explode('|', $raw); $status = $parts[0] ?? ''; $revision = $parts[1] ?? ''; if (!empty($this->options['behaviour']['reset_on_rule_change'])) { if ($revision && (string)$revision !== (string)$this->options['advanced']['revision']) { return ''; } } if ($status === 'allowed' || $status === 'denied' || strpos($status, 'dob:') === 0) { return $status; } return ''; } /** * Writes the status cookie with revision marker. * * @param string $status allowed|denied|dob:{date} * @param int $days Cookie lifetime in days. Zero for session cookie. * @return void */ protected function set_status_cookie(string $status, int $days): void { $value = $status . '|' . $this->options['advanced']['revision']; $expire = $days > 0 ? time() + (DAY_IN_SECONDS * $days) : 0; setcookie( $this->get_cookie_name(), $value, [ 'expires' => $expire, 'path' => '/', 'domain' => $this->get_cookie_domain(), 'secure' => is_ssl(), 'httponly' => false, 'samesite' => 'Lax', ] ); $_COOKIE[$this->get_cookie_name()] = $value; } /** * Resolves cookie domain to current host. * * @return string */ protected function get_cookie_domain(): string { $host = wp_parse_url(home_url(), PHP_URL_HOST); return $host ? $host : ''; } }