array($this, 'fiftyonedegrees_sanitize_resource_key')]); // Suspicious activity detection settings. add_option(Options::SUSPICIOUS_ENABLE, 'off'); add_option(Options::SUSPICIOUS_REDIRECT_URL, ''); add_option(Options::SUSPICIOUS_REQUESTS, 5); add_option(Options::SUSPICIOUS_WINDOW, 30); register_setting(Options::SUSPICIOUS_GROUP_KEY, Options::SUSPICIOUS_ENABLE, [ 'type' => 'string', 'sanitize_callback' => function ($v) { return $v === 'on' ? 'on' : 'off'; }, 'default' => 'off', ]); register_setting(Options::SUSPICIOUS_GROUP_KEY, Options::SUSPICIOUS_REDIRECT_URL, [ 'type' => 'string', 'sanitize_callback' => 'esc_url_raw', 'default' => '', ]); register_setting(Options::SUSPICIOUS_GROUP_KEY, Options::SUSPICIOUS_REQUESTS, [ 'type' => 'integer', 'sanitize_callback' => function ($v) { return max(1, (int) $v); }, 'default' => 5, ]); register_setting(Options::SUSPICIOUS_GROUP_KEY, Options::SUSPICIOUS_WINDOW, [ 'type' => 'integer', 'sanitize_callback' => function ($v) { return max(1, min(3600, (int) $v)); }, 'default' => 30, ]); register_setting(Options::ROBOTS_GROUP_KEY, Options::ROBOTS_ENABLE); register_setting(Options::ROBOTS_GROUP_KEY, Options::ROBOTS_ENFORCE); register_setting( Options::ROBOTS_GROUP_KEY, Options::ROBOTS_REDIRECT_URL, ['sanitize_callback' => ['FiftyoneService', 'sanitize_robots_redirect_url']]); register_setting( Options::ROBOTS_GROUP_KEY, Options::ROBOTS_CUSTOM_TOP, ['sanitize_callback' => ['FiftyoneService', 'sanitize_robots_textarea']]); register_setting( Options::ROBOTS_GROUP_KEY, Options::ROBOTS_CUSTOM_BOTTOM, ['sanitize_callback' => ['FiftyoneService', 'sanitize_robots_textarea']]); register_setting( Options::ROBOTS_GROUP_KEY, Options::ROBOTS_ALLOWED_CATEGORIES, ['sanitize_callback' => ['FiftyoneService', 'sanitize_categories']]); register_setting( Options::ROBOTS_GROUP_KEY, Options::ROBOTS_STANDARD_TDL_SELECTED, ['sanitize_callback' => ['FiftyoneService', 'sanitize_standard_tdl_selected']]); register_setting( Options::ROBOTS_GROUP_KEY, Options::ROBOTS_CUSTOM_TDL, ['sanitize_callback' => ['FiftyoneService', 'sanitize_tdl']]); register_setting(Options::GROUP_KEY, Options::PIPELINE_ENABLE); // PMP (Preference Management Platform) settings. add_option(Options::PMP_ENABLE, 'off'); add_option(Options::PMP_TCF_VENDOR_STRING, ''); add_option(Options::PMP_ALT_LABEL, ''); add_option(Options::PMP_ALT_URL, ''); add_option(Options::PMP_BRAND_NAME, ''); add_option(Options::PMP_BRAND_LOGO_URL, ''); add_option(Options::PMP_BRAND_TERMS_URL, ''); add_option(Options::PMP_SHOW_STANDARD, 'off'); register_setting(Options::PMP_GROUP_KEY, Options::PMP_ENABLE, [ 'type' => 'string', 'sanitize_callback' => ['FiftyoneService', 'sanitize_pmp_enable'], 'default' => 'off', ]); register_setting(Options::PMP_GROUP_KEY, Options::PMP_TCF_VENDOR_STRING, [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ]); register_setting(Options::PMP_GROUP_KEY, Options::PMP_ALT_LABEL, [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ]); register_setting(Options::PMP_GROUP_KEY, Options::PMP_ALT_URL, [ 'type' => 'string', 'sanitize_callback' => 'esc_url_raw', ]); register_setting(Options::PMP_GROUP_KEY, Options::PMP_BRAND_NAME, [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ]); register_setting(Options::PMP_GROUP_KEY, Options::PMP_BRAND_LOGO_URL, [ 'type' => 'string', 'sanitize_callback' => 'esc_url_raw', ]); register_setting(Options::PMP_GROUP_KEY, Options::PMP_BRAND_TERMS_URL, [ 'type' => 'string', 'sanitize_callback' => 'esc_url_raw', ]); register_setting(Options::PMP_GROUP_KEY, Options::PMP_SHOW_STANDARD, [ 'type' => 'string', 'sanitize_callback' => function ($v) { return $v === 'on' ? 'on' : 'off'; }, 'default' => 'off', ]); } static function sanitize_robots_textarea($value) { return sanitize_textarea_field($value); } static function sanitize_robots_redirect_url($value) { return esc_url_raw($value); } static function sanitize_standard_tdl_selected($value) { if (!is_array($value)) { return []; } $valid_ids = array_column(FiftyOneDegreesStandardTdls::load(), 'id'); return array_values(array_unique(array_intersect($value, $valid_ids))); } static function sanitize_categories($value) { if (!is_array($value)) { return []; } $available = FiftyOneDegreesCloudMetadata::fetch_crawler_usage_values(); $valid = array_keys($available); if (empty($valid)) { $valid = ['Index', 'Train', 'Input', 'Search', 'Monitor', 'Archiving', 'Preview', 'Security', 'Analytics', 'Feed', 'Discovery']; } return array_values(array_intersect($value, $valid)); } static function sanitize_tdl($value) { if (is_string($value)) { $lines = explode("\n", $value); } elseif (is_array($value)) { $lines = $value; } else { return []; } $clean = []; foreach ($lines as $url) { $url = esc_url_raw(trim($url)); if (!empty($url)) { $clean[] = $url; } } return array_values(array_unique($clean)); } /** * Sanitizer for Options::PMP_ENABLE. When the caller asks for 'on', * verifies that fields without a sensible default are filled in. * Missing values force the option back to 'off' and register a * settings error listing the missing field labels. * * @param string $value the submitted PMP_ENABLE value * @return string 'on' if validation passes, 'off' otherwise */ static function sanitize_pmp_enable($value) { if ($value !== 'on') { return 'off'; } // Terms / Privacy URL plus the two alternative-button fields // are required server-side. PMP widget rejects an empty alt // button outright; Terms URL has no runtime fallback. The // remaining fields (TCF Vendor String, Brand Name) have // sensible runtime defaults so they stay optional. $required = [ Options::PMP_BRAND_TERMS_URL => 'Terms / Privacy URL', Options::PMP_ALT_LABEL => 'Alternative Button Label', Options::PMP_ALT_URL => 'Alternative Button URL', ]; $missing = []; foreach ($required as $key => $label) { $raw = isset($_POST[$key]) ? wp_unslash($_POST[$key]) : ''; if (trim((string) $raw) === '') { $missing[] = $label; } } if (!empty($missing)) { add_settings_error( Options::PMP_GROUP_KEY, 'pmp_required_missing', sprintf( /* translators: %s is a comma-separated list of field labels. */ __('PMP cannot be enabled: missing required field(s): %s.', 'fiftyonedegrees'), implode(', ', $missing) ), 'error' ); return 'off'; } return 'on'; } /** * Register the JSON endpoint for the pipeline. This is where the * JavaScript will callback to instead of the 51Degrees domain. * * @return void */ function fiftyonedegrees_rest_api_init() { register_rest_route('fiftyonedegrees/v4', "json", array( 'methods' => 'POST', 'args' => array(), 'callback' => array('Pipeline','getJSON'), 'permission_callback' => '__return_true' )); } /** * Checks if the Resource Key has been changed, and stores the new one * if it has. When the new option has been updated, the pipeline will be * rebuilt. * * @return void */ function submit_rk_submit_action() { if (isset($_POST[Options::RESOURCE_KEY]) && isset($_POST["action"]) && $_POST[Options::RESOURCE_KEY] !== get_option(Options::RESOURCE_KEY)) { $resource_key = sanitize_text_field(wp_unslash( $_POST[Options::RESOURCE_KEY])); update_option(Options::RESOURCE_KEY, $resource_key); // update_option above triggers a synchronous pipeline rebuild // that sets PIPELINE_VALIDATION_ERROR on failure. if (!get_option(Options::PIPELINE_VALIDATION_ERROR)) { if (get_option(Options::ENABLE_GA) && get_option(Options::RESOURCE_KEY_UPDATED)) { wp_redirect(get_admin_url() . 'options-general.php?page=51Degrees&tab=google-analytics'); exit(); } } else { wp_redirect(get_admin_url() . 'options-general.php?page=51Degrees&tab=setup' ); exit(); } } } /** * Add stylesheet for admin pages. * * @return void */ function fiftyonedegrees_admin_enqueue_scripts() { wp_enqueue_style( "fiftyonedegrees_admin_styles", plugin_dir_url(__FILE__) . "../assets/css/fod.css"); wp_enqueue_style( "fiftyonedegrees_admin_styles_icons", "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css"); wp_enqueue_script( "fiftyonedegrees_jQuery", plugin_dir_url(__FILE__) . '../assets/js/51D.js', array('jquery') #dependencies ); } /** * After any option is updated, check if the option was something that * needs to be taken care of. For Resource Key, the flow data needs to * be removed from the session cache, and a new pipeline created. * * @return void */ function fiftyonedegrees_update_option($option, $old_value, $new_value) { if ($option === Options::RESOURCE_KEY) { // Remove the cached flowdata from the session. if (session_status() === PHP_SESSION_ACTIVE && isset($_SESSION["fiftyonedegrees_data"])) { unset($_SESSION["fiftyonedegrees_data"]); update_option( Options::SESSION_INVALIDATED, time()); } // Invalidate cloud metadata transient FiftyOneDegreesCloudMetadata::invalidate_all(); // Stale against the previous key — clear. delete_option(Options::ROBOTS_LAST_REFRESH); self::build_and_save_pipeline($new_value); if ($old_value !== $new_value) { update_option(Options::RESOURCE_KEY_UPDATED, true); delete_option(Options::GA_DIMENSIONS); } else { delete_option(Options::RESOURCE_KEY_UPDATED); } } // GA4 property selection invalidates any saved // property->parameter dimension mapping — the new property // may not share Custom Dimensions with the old one. if ($option === Options::GA_PROPERTY_ID && $old_value !== $new_value) { update_option(Options::GA_ID_UPDATED, true); delete_option(Options::GA_DIMENSIONS); } if ($option === Options::GA_SEND_PAGE_VIEW_VAL && $old_value !== $new_value) { update_option(Options::GA_SEND_PAGE_VIEW_UPDATED, true); } if ($option === Options::ROBOTS_ENFORCE && $new_value === 'on' && get_option(Options::PIPELINE_ENABLE, 'on') !== 'on') { update_option(Options::PIPELINE_ENABLE, 'on'); set_transient( 'fiftyonedegrees_pipeline_auto_enabled', 'Robots Enforce', 30); } if ($option === Options::PIPELINE_ENABLE && $new_value === 'off' && get_option(Options::ROBOTS_ENFORCE, 'off') === 'on') { update_option(Options::ROBOTS_ENFORCE, 'off'); } } /** * Builds the pipeline when the resource key option is first created, * e.g. via WP-CLI where update_option hook does not fire. * * @param string $option the option name * @param mixed $value the option value * @return void */ function fiftyonedegrees_add_option($option, $value) { if ($option === Options::RESOURCE_KEY && $value) { self::build_and_save_pipeline($value); } } /** * register_setting sanitize_callback for the Resource Key. Runs on every * settings save, even when the value is unchanged — which is the only * place we can re-validate an unchanged key. WordPress fires * updated_option (and the rebuild wired to it in fiftyonedegrees_update_option) * only when the value actually changes, so without this an unchanged * re-save never re-validates and a stale "rejected" error persists even * after its cause is fixed. Changed keys are left to the updated_option * path to avoid a duplicate cloud call. * * @param mixed $value the submitted resource key * @return mixed the value to persist (unchanged) */ static function fiftyonedegrees_sanitize_resource_key($value) { if ($value === get_option(Options::RESOURCE_KEY)) { self::build_and_save_pipeline($value); } return $value; } // On error: leave PIPELINE alone, record error string for setup.php. private static function build_and_save_pipeline($resource_key) { // Empty key: nothing to validate. Drop all cached state so the setup // tab renders clean (no stale red box from a previous failed save). if (empty($resource_key)) { delete_option(Options::PIPELINE); delete_option(Options::PIPELINE_VALIDATION_ERROR); return; } $pipeline = Pipeline::make_pipeline($resource_key); if ($pipeline && !isset($pipeline['error'])) { update_option(Options::PIPELINE, $pipeline); delete_option(Options::PIPELINE_VALIDATION_ERROR); return; } if (is_array($pipeline) && isset($pipeline['error']) && $pipeline['error'] !== '') { update_option(Options::PIPELINE_VALIDATION_ERROR, (string) $pipeline['error']); } } /** * Called on admin_init. When returning to the robots settings page after a * settings save (WordPress adds settings-updated=true to the URL), triggers * an immediate Cloud API call to refresh the cached robots.txt content. * * @return void */ function fiftyonedegrees_re_generate_robots() { if (!isset($_GET['settings-updated'])) { return; } $page = isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : ''; $tab = isset($_GET['tab']) ? sanitize_text_field(wp_unslash($_GET['tab'])) : ''; if ($page !== '51Degrees' || $tab !== 'robots') { return; } $success = FiftyOneDegreesRobotsTxt::fetch_from_cloud(); if ($success) { set_transient('fiftyonedegrees_robots_generate_success', true, 30); } } /** * Displays an admin notice when device detection was automatically enabled * because a dependent feature (e.g. Robots Enforce) requires it. * * @return void */ function fiftyonedegrees_pipeline_autoenable_notice() { $feature = get_transient('fiftyonedegrees_pipeline_auto_enabled'); if ($feature === false) { return; } delete_transient('fiftyonedegrees_pipeline_auto_enabled'); echo '
' . '51Degrees: Device detection was automatically ' . 'enabled because ' . esc_html($feature) . ' requires it.' . '
' . '51Degrees: ' . esc_html($message) . '