diff --git a/cleantalk.php b/cleantalk.php
index 51cec47fc..1b548c318 100644
--- a/cleantalk.php
+++ b/cleantalk.php
@@ -887,12 +887,15 @@ function apbct_sfw__check()
global $apbct, $spbc, $cleantalk_url_exclusions;
// Turn off the SpamFireWall if current url in the exceptions list and WordPress core pages
+ $core_page_to_skip_check = array('/feed');
if ( ! empty($cleantalk_url_exclusions) && is_array($cleantalk_url_exclusions) ) {
- $core_page_to_skip_check = array('/feed');
- foreach ( array_merge($cleantalk_url_exclusions, $core_page_to_skip_check) as $v ) {
- if ( apbct_is_in_uri($v) ) {
- return;
- }
+ $cleantalk_url_exclusions = array_merge($cleantalk_url_exclusions, $core_page_to_skip_check);
+ } else {
+ $cleantalk_url_exclusions = $core_page_to_skip_check;
+ }
+ foreach ( $cleantalk_url_exclusions as $v ) {
+ if ( apbct_is_in_uri($v) ) {
+ return;
}
}
diff --git a/composer.json b/composer.json
index a195e0821..2407717c7 100644
--- a/composer.json
+++ b/composer.json
@@ -8,6 +8,9 @@
"email": "plugins@cleantalk.org"
}
],
+ "require": {
+ "cleantalk/rate-limiter": "*"
+ },
"require-dev": {
"vimeo/psalm": "^4.8",
"phpunit/phpunit": "^8.5.52",
diff --git a/inc/cleantalk-settings.php b/inc/cleantalk-settings.php
index 97a638250..a8ea76ead 100644
--- a/inc/cleantalk-settings.php
+++ b/inc/cleantalk-settings.php
@@ -112,8 +112,7 @@ function apbct_settings__set_fields()
'cleantalk-spam-protect'
)
. ' '
- //HANDLE LINK
- . ''
+ . ' '
. esc_html__(
'contact to our support.',
'cleantalk-spam-protect'
@@ -288,7 +287,7 @@ function apbct_settings__set_fields()
'Read more about %sspam protection for Search form%s on our blog. The "noindex" tag will be placed in the meta directive on the search results page.',
'cleantalk-spam-protect'
),
- ' ',
+ ' ',
' '
)
: ''
@@ -526,9 +525,7 @@ function apbct_settings__set_fields()
. ( ! $apbct->white_label && ! $apbct->data["wl_mode_enabled"] ?
__(' Or you don`t have records about missed spam here:', 'cleantalk-spam-protect')
. ' '
- //HANDLE LINK
- . ''
+ . ' '
. __('CleanTalk Dashboard', 'cleantalk-spam-protect')
. ' .' : '' )
. ' '
@@ -665,7 +662,7 @@ function apbct_settings__set_fields()
),
'data__email_check_exist_post' => array(
'title' => __('Show email existence alert when filling in the field', 'cleantalk-spam-protect'),
- 'description' => __('Checks the email address and shows the result as an icon in the email field before submitting the form. Works for WooCommerce checkout form, FluentForms, Contact Form 7, Gravity Forms, Ninja Forms, WPForms, standard WordPress comment form and registration form.', 'cleantalk-spam-protect') . ' ' . '' . __('Read more', 'cleantalk-spam-protect') . ' ',
+ 'description' => __('Checks the email address and shows the result as an icon in the email field before submitting the form. Works for WooCommerce checkout form, FluentForms, Contact Form 7, Gravity Forms, Ninja Forms, WPForms, standard WordPress comment form and registration form.', 'cleantalk-spam-protect') . ' ' . '' . __('Read more', 'cleantalk-spam-protect') . ' ',
),
),
),
@@ -1125,8 +1122,7 @@ function apbct_settings__set_fields__network($fields)
'title' => __('Hoster Access key', 'cleantalk-spam-protect'),
'description' => sprintf(
__('Copy the Access key from your %sCleanTalk Profile%s', 'cleantalk-spam-protect'),
- //HANDLE LINK
- '',
+ ' ',
' '
),
'class' => 'apbct_settings-field_wrapper--sub',
@@ -1370,8 +1366,7 @@ function apbct_settings__display()
if ( $apbct->key_is_ok && apbct_api_key__is_correct() && ! $apbct->data["wl_mode_enabled"] ) {
if ( $apbct->network_settings['multisite__work_mode'] != 2 || is_main_site() ) {
// CP button
- //HANDLE LINK
- echo ''
+ echo ' '
. __('Cloud Dashboard', 'cleantalk-spam-protect')
. ' '
. ' ';
@@ -1874,9 +1869,8 @@ function apbct_settings__field__state()
'cleantalk-spam-protect'
);
if ( ! $apbct->white_label || is_main_site() ) {
- //HANDLE LINK
echo ' '
- . '' . __(
+ . ' ' . __(
'Validate email for existence',
'cleantalk-spam-protect'
) . ' ';
@@ -2010,7 +2004,7 @@ function apbct_settings__field__apikey()
$replaces['public_offer_link'] = sprintf(
'' .
__('I accept %sLicense Agreement%s.', 'cleantalk-spam-protect'),
- '',
+ ' ',
' '
);
@@ -3025,6 +3019,24 @@ function apbct_settings__get__long_description()
}
$setting_id = TT::toString(Post::get('setting_id', null, 'word'));
+ $descriptions = apbct_settings__get_long_descriptions_data();
+
+ if (!empty($setting_id) && isset($descriptions[$setting_id])) {
+ die(json_encode($descriptions[$setting_id]));
+ } else {
+ die(json_encode(['error' => 'Invalid setting ID']));
+ }
+}
+
+/**
+ * Builds the long descriptions data array with all LinkConstructor links.
+ * Extracted for testability.
+ *
+ * @return array
+ */
+function apbct_settings__get_long_descriptions_data()
+{
+ global $apbct;
$link_exclusion_by_form_signs = LinkConstructor::buildCleanTalkLink(
'exclusion_by_form_signs',
@@ -3046,18 +3058,16 @@ function apbct_settings__get__long_description()
),
'data__set_cookies' => array(
'title' => __('Cookies setting', 'cleantalk-spam-protect'),
- //HANDLE LINK
'desc' => sprintf(
__('It determines what methods of using the HTTP cookies the Anti-Spam plugin for WordPress should switch to. It is necessary for the plugin to work properly. All CleanTalk cookies contain technical data. Data of the current website visitor is encrypted with the MD5 algorithm and being deleted when the browser session ends. %s', 'cleantalk-spam-protect'),
- '' . __('Learn more about suboptions', 'cleantalk-spam-protect') . ' '
+ '' . __('Learn more about suboptions', 'cleantalk-spam-protect') . ' '
)
),
'comments__hide_website_field' => array(
'title' => __('Hide the "Website" field', 'cleantalk-spam-protect'),
- //HANDLE LINK
'desc' => sprintf(
- __('This «Website» field is frequently used by spammers to place spam links in it. ' . esc_html__($apbct->data['wl_brandname']) . ' helps you protect your WordPress website comments by hiding this field off. %s', 'cleantalk-spam-protect'),
- '' . __('Learn more.', 'cleantalk-spam-protect') . ' '
+ __('This «Website» field is frequently used by spammers to place spam links in it. ' . esc_html($apbct->data['wl_brandname']) . ' helps you protect your WordPress website comments by hiding this field off. %s', 'cleantalk-spam-protect'),
+ '' . __('Learn more.', 'cleantalk-spam-protect') . ' '
)
),
'comments__the_real_person' => array(
@@ -3071,50 +3081,45 @@ function apbct_settings__get__long_description()
),
'sfw__anti_crawler' => array(
'title' => 'Anti-Crawler', // Do not to localize this phrase
- //HANDLE LINK
'desc' => sprintf(
'
' . sprintf(
__('%1$s Anti-Crawler — this option is meant to block all types of bots visiting website pages that can search vulnerabilities on a website, attempt to hack a site, collect personal data, price parsing or content and images, generate 404 error pages, or aggressive website scanning bots. %2$s', 'cleantalk-spam-protect'),
esc_html($apbct->data['wl_brandname']),
- '' . __('Learn more.', 'cleantalk-spam-protect') . ' '
+ '' . __('Learn more.', 'cleantalk-spam-protect') . ' '
) . '
' .
'' . __('By default, well-known good bots are allowed, including AI crawlers like GPTBot (ChatGPT), ClaudeBot (Claude), Google-Extended (Gemini), and Copilot. You can selectively block any of these bots in your private lists.', 'cleantalk-spam-protect') . '
'
)
),
'sfw__anti_flood' => array(
'title' => 'Anti-Flood', // Do not to localize this phrase
- //HANDLE LINK
'desc' => sprintf(
- __(esc_html__($apbct->data['wl_brandname']) . ' Anti-Flood — this option is meant to block aggressive bots. You can set the maximum number of website pages your visitors can click on within 1 minute. If any IP exceeds the set number it will get the ' . $apbct->data['wl_brandname'] . ' blocking screen for 30 seconds. It\'s impossible for the IP to open any website pages while the 30-second timer takes place. %s', 'cleantalk-spam-protect'),
- '' . __('Learn more.', 'cleantalk-spam-protect') . ' '
+ __(esc_html($apbct->data['wl_brandname']) . ' Anti-Flood — this option is meant to block aggressive bots. You can set the maximum number of website pages your visitors can click on within 1 minute. If any IP exceeds the set number it will get the ' . esc_html($apbct->data['wl_brandname']) . ' blocking screen for 30 seconds. It\'s impossible for the IP to open any website pages while the 30-second timer takes place. %s', 'cleantalk-spam-protect'),
+ '' . __('Learn more.', 'cleantalk-spam-protect') . ' '
)
),
'data__pixel' => array(
- 'title' => __(esc_html__($apbct->data['wl_brandname']) . ' Pixel', 'cleantalk-spam-protect'),
- //HANDLE LINK
+ 'title' => __(esc_html($apbct->data['wl_brandname']) . ' Pixel', 'cleantalk-spam-protect'),
'desc' => sprintf(
__('It is an «invisible» 1×1px image that the Anti-Spam plugin integrates to your WordPress website. And when someone visits your website the Pixel is triggered and reports this visit and some other data including true IP address. %s', 'cleantalk-spam-protect'),
- '' . __('Learn more.', 'cleantalk-spam-protect') . ' '
+ '' . __('Learn more.', 'cleantalk-spam-protect') . ' '
)
),
'data__honeypot_field' => array(
'title' => __('Honeypot field', 'cleantalk-spam-protect'),
- //HANDLE LINK
'desc' => sprintf(
esc_html__('The option helps to block bots . The honeypot field option adds a hidden field to the form. When spambots come to a website form, they can fill out each input field. Enable this option to make the protection stronger on these forms. Learn more about supported forms %s', 'cleantalk-spam-protect'),
- '' . __('here.', 'cleantalk-spam-protect') . ' '
+ '' . __('here.', 'cleantalk-spam-protect') . ' '
)
),
'sfw__enabled' => array(
'title' => 'SpamFireWall',
- //HANDLE LINK
'desc' => sprintf(
'' . esc_html__('SpamFireWall is a part of the anti-spam service and blocks the most spam active bots before the site pages load.', 'cleantalk-spam-protect') . '
'
. '' . esc_html__('Anti-Crawler is an add-on to SFW and helps to strengthen the protection against spam bots. Disabled by default.', 'cleantalk-spam-protect') . '
'
- . '' . esc_html__($apbct->data['wl_brandname'] . ' Anti-Flood is also an add-on to SFW and limits the number of pages visited per minute. Disabled by default.', 'cleantalk-spam-protect') . '
'
+ . '' . esc_html($apbct->data['wl_brandname']) . __(' Anti-Flood is also an add-on to SFW and limits the number of pages visited per minute. Disabled by default.', 'cleantalk-spam-protect') . '
'
. '' . esc_html__('You can read more about SFW modes %s', 'cleantalk-spam-protect') . '
'
. '' . esc_html__('Read out the article if you are using Varnish on your server.', 'cleantalk-spam-protect'),
- '' . __('here.', 'cleantalk-spam-protect') . ' '
+ '' . __('here.', 'cleantalk-spam-protect') . ' '
)
),
'exclusions__form_signs' => array(
@@ -3188,17 +3193,7 @@ function apbct_settings__get__long_description()
),
);
- if (!empty($setting_id) && isset($descriptions[$setting_id])) {
- if ($setting_id === 'comments__hide_website_field') {
- $utm = '?utm_source=apbct_hint_' . esc_attr($setting_id) . '&utm_medium=hide_website_field_hint&utm_campaign=apbct_links';
- } else {
- $utm = '?utm_source=apbct_hint_' . esc_attr($setting_id) . '&utm_medium=WordPress&utm_campaign=ABPCT_Settings';
- }
- $descriptions[$setting_id]['desc'] = str_replace('{utm_mark}', $utm, $descriptions[$setting_id]['desc']);
- die(json_encode($descriptions[$setting_id]));
- } else {
- die(json_encode(['error' => 'Invalid setting ID']));
- }
+ return $descriptions;
}
function apbct_settings__check_renew_banner()
diff --git a/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php b/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php
index 59deea4b9..e6e102d84 100644
--- a/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php
+++ b/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php
@@ -8,6 +8,7 @@
use Cleantalk\ApbctWP\Variables\Cookie;
use Cleantalk\ApbctWP\Variables\Get;
use Cleantalk\ApbctWP\Variables\Post;
+use Cleantalk\ApbctWP\WcFieldSwapHoneypot;
use Cleantalk\ApbctWP\WcSpamOrdersFunctions;
use Cleantalk\ApbctWP\Variables\Request;
use Cleantalk\ApbctWP\Variables\Server;
@@ -46,6 +47,7 @@ public function doPublicWork()
// honeypot
add_filter('woocommerce_checkout_fields', [$this, 'addHoneypotField']);
+ WcFieldSwapHoneypot::registerHooks();
// add to cart hooks if cart works with non-ajax requests
$this->addCartActions();
@@ -174,7 +176,10 @@ public function checkoutCheck($_data, $errors)
{
global $apbct, $cleantalk_executed;
- if ( count($errors->errors) ) {
+ $swap_hp = WcFieldSwapHoneypot::getCheckoutResult();
+ $honeypot_triggered = $swap_hp['status'] === 0;
+
+ if ( count($errors->errors) && ! $honeypot_triggered ) {
return;
}
@@ -188,6 +193,7 @@ public function checkoutCheck($_data, $errors)
* Filter for POST
*/
$input_array = apply_filters('apbct__filter_post', $_POST);
+ $input_array = WcFieldSwapHoneypot::enrichInputArrayForCleanTalk($input_array);
//Getting request params
$ct_temp_msg_data = ct_get_fields_any($input_array);
@@ -198,6 +204,10 @@ public function checkoutCheck($_data, $errors)
$subject = isset($ct_temp_msg_data['subject']) ? $ct_temp_msg_data['subject'] : '';
$message = isset($ct_temp_msg_data['message']) ? $ct_temp_msg_data['message'] : array();
+ if ( $honeypot_triggered && $sender_email === '' && ! empty($swap_hp['value']) ) {
+ $sender_email = $swap_hp['value'];
+ }
+
if ( $subject != '' ) {
$message = array_merge(array('subject' => $subject), $message);
}
@@ -206,12 +216,20 @@ public function checkoutCheck($_data, $errors)
$post_info['comment_type'] = 'order';
$post_info['post_url'] = Server::get('HTTP_REFERER');
+ $sender_info = array(
+ 'sender_url' => null,
+ 'sender_emails_array' => $sender_emails_array,
+ 'wc_field_swap_honeypot' => $swap_hp['status'],
+ 'wc_field_swap_honeypot_value' => $swap_hp['value'],
+ 'wc_field_swap_honeypot_source' => $swap_hp['source'],
+ );
+
$base_call_data = array(
'message' => $message,
'sender_email' => $sender_email,
'sender_nickname' => $sender_nickname,
'post_info' => $post_info,
- 'sender_info' => array('sender_url' => null, 'sender_emails_array' => $sender_emails_array)
+ 'sender_info' => $sender_info,
);
$base_call_result = apbct_base_call($base_call_data);
diff --git a/lib/Cleantalk/ApbctWP/API.php b/lib/Cleantalk/ApbctWP/API.php
index 55024bf82..d137a851b 100644
--- a/lib/Cleantalk/ApbctWP/API.php
+++ b/lib/Cleantalk/ApbctWP/API.php
@@ -83,12 +83,27 @@ public static function sendRequest($data, $timeout = 10)
// Adding agent version to data
$data['agent'] = defined('APBCT_AGENT') ? APBCT_AGENT : '';
+ $options = ['timeout' => $timeout];
+ // Proxy settings from wp-config.php constants.
+ if ( defined('WP_PROXY_HOST') && WP_PROXY_HOST !== '' && defined('WP_PROXY_PORT') && WP_PROXY_PORT !== '' ) {
+ $proxy_port = (int) WP_PROXY_PORT;
+ $options['proxy'] = WP_PROXY_HOST . ':' . $proxy_port; // WP HTTP API branch
+ // cURL branch (avoid referencing CURLOPT_* when ext-curl is not available)
+ if ( function_exists('curl_init') && defined('CURLOPT_PROXY') && defined('CURLOPT_PROXYPORT') ) {
+ $options[CURLOPT_PROXY] = WP_PROXY_HOST;
+ $options[CURLOPT_PROXYPORT] = $proxy_port;
+ if ( defined('WP_PROXY_USERNAME') && WP_PROXY_USERNAME !== '' && defined('CURLOPT_PROXYUSERPWD') ) {
+ $options[CURLOPT_PROXYUSERPWD] = WP_PROXY_USERNAME . ':' . (defined('WP_PROXY_PASSWORD') ? WP_PROXY_PASSWORD : '');
+ }
+ }
+ }
+
$http = new Request();
$request = $http->setUrl($url)
->setData($data)
->setPresets(['retry_with_socket'])
- ->setOptions(['timeout' => $timeout]);
+ ->setOptions($options);
if ( isset($data['method_name']) ) {
$request->addCallback(
__CLASS__ . '::checkResponse',
diff --git a/lib/Cleantalk/ApbctWP/Firewall/AntiCrawler.php b/lib/Cleantalk/ApbctWP/Firewall/AntiCrawler.php
index de4fee19a..bb7036b91 100644
--- a/lib/Cleantalk/ApbctWP/Firewall/AntiCrawler.php
+++ b/lib/Cleantalk/ApbctWP/Firewall/AntiCrawler.php
@@ -575,15 +575,15 @@ public function diePage($result)
3
) . ' '
. __('Don\'t close this page. Please, wait for 3 seconds to pass to the page.', 'cleantalk-spam-protect'),
- '{CLEANTALK_TITLE}' => $apbct->data['wl_brandname'],
- '{CLEANTALK_URL}' => $apbct->data['wl_url'],
- '{REMOTE_ADDRESS}' => $ip,
- '{SERVICE_ID}' => $this->apbct->data['service_id'] . ', ' . $net_count,
+ '{CLEANTALK_TITLE}' => esc_html($apbct->data['wl_brandname']),
+'{CLEANTALK_URL}' => esc_url($apbct->data['wl_url']),
+ '{REMOTE_ADDRESS}' => esc_html($ip),
+ '{SERVICE_ID}' => esc_html($this->apbct->data['service_id']) . ', ' . esc_html($net_count),
'{HOST}' => get_home_url() . ', ' . APBCT_VERSION,
'{COOKIE_ANTICRAWLER}' => hash('sha256', $apbct->api_key . $apbct->data['salt']),
'{COOKIE_ANTICRAWLER_PASSED}' => '1',
'{GENERATED}' => '
The page was generated at ' . date('D, d M Y H:i:s') . "
",
- '{SCRIPT_URL}' => $js_url,
+'{SCRIPT_URL}' => esc_url($js_url),
// Custom Logo
'{CUSTOM_LOGO}' => $custom_logo_img
@@ -595,13 +595,13 @@ public function diePage($result)
if ( Get::get('debug') ) {
$debug = 'Headers '
- . str_replace("\n", " ", print_r(\apache_request_headers(), true))
+ . esc_html(str_replace("\n", " ", print_r(\apache_request_headers(), true)))
. '$_SERVER '
- . str_replace("\n", " ", print_r($_SERVER, true))
+ . esc_html(str_replace("\n", " ", print_r($_SERVER, true)))
. 'AC_LOG_RESULT '
- . str_replace("\n", " ", print_r($this->ac_log_result, true))
+ . esc_html(str_replace("\n", " ", print_r($this->ac_log_result, true)))
. 'IPS '
- . str_replace("\n", " ", print_r($this->ip_array, true));
+ . esc_html(str_replace("\n", " ", print_r($this->ip_array, true)));
} else {
$debug = '';
}
diff --git a/lib/Cleantalk/ApbctWP/Firewall/AntiFlood.php b/lib/Cleantalk/ApbctWP/Firewall/AntiFlood.php
index ab68f8fb5..c49a0fa96 100644
--- a/lib/Cleantalk/ApbctWP/Firewall/AntiFlood.php
+++ b/lib/Cleantalk/ApbctWP/Firewall/AntiFlood.php
@@ -258,15 +258,15 @@ public function diePage($result)
),
30
),
- '{CLEANTALK_TITLE}' => $apbct->data['wl_brandname'],
- '{CLEANTALK_URL}' => $apbct->data['wl_url'],
- '{REMOTE_ADDRESS}' => $result['ip'],
- '{REQUEST_URI}' => Server::get('REQUEST_URI'),
- '{SERVICE_ID}' => $this->apbct->data['service_id'] . ', ' . $net_count,
+ '{CLEANTALK_TITLE}' => esc_html($apbct->data['wl_brandname']),
+'{CLEANTALK_URL}' => esc_url($apbct->data['wl_url']),
+ '{REMOTE_ADDRESS}' => esc_html($result['ip']),
+ '{REQUEST_URI}' => esc_html(Server::getString('REQUEST_URI')),
+ '{SERVICE_ID}' => esc_html($this->apbct->data['service_id']) . ', ' . esc_html($net_count),
'{HOST}' => get_home_url() . ', ' . APBCT_VERSION,
'{GENERATED}' => 'The page was generated at ' . date('D, d M Y H:i:s') . "
",
'{COOKIE_ANTIFLOOD_PASSED}' => md5($result['ip'] . $this->api_key),
- '{SCRIPT_URL}' => $js_url,
+'{SCRIPT_URL}' => esc_url($js_url),
// Custom Logo
'{CUSTOM_LOGO}' => $custom_logo_img
diff --git a/lib/Cleantalk/ApbctWP/Firewall/SFW.php b/lib/Cleantalk/ApbctWP/Firewall/SFW.php
index 5a33977ba..1280701ed 100644
--- a/lib/Cleantalk/ApbctWP/Firewall/SFW.php
+++ b/lib/Cleantalk/ApbctWP/Firewall/SFW.php
@@ -398,7 +398,7 @@ public function diePage($result)
$block_message = sprintf(
esc_html__('SpamFireWall is checking your browser and IP %s for spam bots', 'cleantalk-spam-protect'),
- '' . $result['ip'] . ' '
+ '' . esc_html($result['ip']) . ' '
);
$request_uri = $this->server__request_uri;
@@ -437,16 +437,16 @@ public function diePage($result)
'This is the testing page for SpamFireWall',
'cleantalk-spam-protect'
) : ''),
- '{CLEANTALK_URL}' => $apbct->data['wl_url'],
- '{REMOTE_ADDRESS}' => $result['ip'],
- '{SERVICE_ID}' => $apbct->data['service_id'] . ', ' . $net_count,
+ '{CLEANTALK_URL}' => esc_url($apbct->data['wl_url']),
+ '{REMOTE_ADDRESS}' => esc_html($result['ip']),
+ '{SERVICE_ID}' => esc_html($apbct->data['service_id']) . ', ' . esc_html($net_count),
'{HOST}' => get_home_url() . ', ' . APBCT_VERSION,
'{GENERATED}' => 'The page was generated at ' . date('D, d M Y H:i:s') . '
',
- '{REQUEST_URI}' => $request_uri,
+ '{REQUEST_URI}' => esc_url($request_uri),
// Cookie
'{COOKIE_PREFIX}' => '',
- '{COOKIE_DOMAIN}' => $this->cookie_domain,
+ '{COOKIE_DOMAIN}' => esc_html($this->cookie_domain),
'{COOKIE_SFW}' => $cookie_val,
'{COOKIE_ANTICRAWLER}' => hash('sha256', $apbct->api_key . $apbct->data['salt']),
@@ -456,13 +456,13 @@ public function diePage($result)
'{TEST_IP__HEADER}' => '',
'{TEST_IP}' => '',
'{REAL_IP}' => '',
- '{SCRIPT_URL}' => $js_url,
+'{SCRIPT_URL}' => esc_url($js_url),
// Message about IP status
'{MESSAGE_IP_STATUS}' => '',
// Custom Logo
- '{CUSTOM_LOGO}' => $custom_logo_img
+'{CUSTOM_LOGO}' => wp_kses_post($custom_logo_img)
);
/**
@@ -491,7 +491,7 @@ public function diePage($result)
break;
}
- $replaces['{MESSAGE_IP_STATUS}'] = "$message_ip_status ";
+ $replaces['{MESSAGE_IP_STATUS}'] = "" . esc_html($message_ip_status) . " ";
}
// Test
@@ -502,22 +502,22 @@ public function diePage($result)
);
$replaces['{REAL_IP__HEADER}'] = 'Real IP:';
$replaces['{TEST_IP__HEADER}'] = 'Test IP:';
- $replaces['{TEST_IP}'] = $this->test_ip;
- $replaces['{REAL_IP}'] = $this->real_ip;
+ $replaces['{TEST_IP}'] = esc_html($this->test_ip);
+ $replaces['{REAL_IP}'] = esc_html($this->real_ip);
}
// Debug
if ($this->debug) {
$debug = 'Headers '
- . var_export(apache_request_headers(), true)
+ . '' . esc_html(var_export(apache_request_headers(), true)) . ' '
. 'REMOTE_ADDR '
- . $this->server__remote_addr
+ . esc_html($this->server__remote_addr)
. 'SERVER_ADDR '
- . $this->server__remote_addr
+ . esc_html($this->server__remote_addr)
. 'IP_ARRAY '
- . var_export($this->ip_array, true)
+ . esc_html(var_export($this->ip_array, true))
. 'ADDITIONAL '
- . var_export($this->debug_data, true);
+ . esc_html(var_export($this->debug_data, true));
}
$replaces['{DEBUG}'] = isset($debug) ? $debug : '';
diff --git a/lib/Cleantalk/ApbctWP/HTTP/HTTPMultiRequestService.php b/lib/Cleantalk/ApbctWP/HTTP/HTTPMultiRequestService.php
index 25ffdffaa..997bdd638 100644
--- a/lib/Cleantalk/ApbctWP/HTTP/HTTPMultiRequestService.php
+++ b/lib/Cleantalk/ApbctWP/HTTP/HTTPMultiRequestService.php
@@ -131,18 +131,18 @@ public function executeMultiContract()
*/
public function fillMultiContract($http_multi_result)
{
- // Handle HTTP request error
- if (!empty($http_multi_result['error'])) {
- $this->error_msg = __CLASS__ . ': HTTP_MULTI_RESULT ERROR' . $http_multi_result['error'];
- return $this;
- }
-
// Validate result is an array
if (!is_array($http_multi_result)) {
$this->error_msg = __CLASS__ . ': HTTP_MULTI_RESULT INVALID';
return $this;
}
+ // Handle HTTP request error
+ if (!empty($http_multi_result['error'])) {
+ $this->error_msg = __CLASS__ . ': HTTP_MULTI_RESULT ERROR: ' . $http_multi_result['error'];
+ return $this;
+ }
+
// Fill each contract with corresponding response data
foreach ($this->contracts as $contract) {
if (isset($http_multi_result[$contract->url])) {
diff --git a/lib/Cleantalk/ApbctWP/LinkConstructor.php b/lib/Cleantalk/ApbctWP/LinkConstructor.php
index 7f4bde96a..aa87d8168 100644
--- a/lib/Cleantalk/ApbctWP/LinkConstructor.php
+++ b/lib/Cleantalk/ApbctWP/LinkConstructor.php
@@ -230,6 +230,116 @@ class LinkConstructor extends \Cleantalk\Common\LinkConstructor
'utm_content' => 'avatar',
'utm_campaign' => 'apbct_links',
),
+ /*
+ * Settings long descriptions (hints)
+ */
+ 'apbct_hint_data__set_cookies' => array(
+ 'utm_id' => '',
+ 'utm_term' => '',
+ 'utm_source' => 'apbct_hint_data__set_cookies',
+ 'utm_medium' => 'WordPress',
+ 'utm_campaign' => 'ABPCT_Settings',
+ ),
+ 'apbct_hint_comments__hide_website_field' => array(
+ 'utm_id' => '',
+ 'utm_term' => '',
+ 'utm_source' => 'apbct_hint_comments__hide_website_field',
+ 'utm_medium' => 'hide_website_field_hint',
+ ),
+ 'apbct_hint_sfw__anti_crawler' => array(
+ 'utm_id' => '',
+ 'utm_term' => '',
+ 'utm_source' => 'apbct_hint_sfw__anti_crawler',
+ 'utm_medium' => 'WordPress',
+ 'utm_campaign' => 'ABPCT_Settings',
+ ),
+ 'apbct_hint_sfw__anti_flood' => array(
+ 'utm_id' => '',
+ 'utm_term' => '',
+ 'utm_source' => 'apbct_hint_sfw__anti_flood',
+ 'utm_medium' => 'WordPress',
+ 'utm_campaign' => 'ABPCT_Settings',
+ ),
+ 'apbct_hint_data__pixel' => array(
+ 'utm_id' => '',
+ 'utm_term' => '',
+ 'utm_source' => 'apbct_hint_data__pixel',
+ 'utm_medium' => 'WordPress',
+ 'utm_campaign' => 'ABPCT_Settings',
+ ),
+ 'apbct_hint_data__honeypot_field' => array(
+ 'utm_id' => '',
+ 'utm_term' => '',
+ 'utm_source' => 'apbct_hint_data__honeypot_field',
+ 'utm_medium' => 'WordPress',
+ 'utm_campaign' => 'ABPCT_Settings',
+ ),
+ 'apbct_hint_sfw__enabled' => array(
+ 'utm_id' => '',
+ 'utm_term' => '',
+ 'utm_source' => 'apbct_hint_sfw__enabled',
+ 'utm_medium' => 'WordPress',
+ 'utm_campaign' => 'ABPCT_Settings',
+ ),
+ /*
+ * Settings page links
+ */
+ 'settings_support_open' => array(
+ 'utm_id' => '',
+ 'utm_term' => '',
+ 'utm_source' => 'admin_panel',
+ 'utm_medium' => 'settings',
+ 'utm_content' => 'sfw_support_link',
+ ),
+ 'blog_search_form_protection' => array(
+ 'utm_id' => '',
+ 'utm_term' => '',
+ 'utm_source' => 'admin_panel',
+ 'utm_medium' => 'settings',
+ 'utm_content' => 'search_form_protection',
+ ),
+ 'settings_dashboard_link' => array(
+ 'utm_id' => '',
+ 'utm_term' => '',
+ 'utm_source' => 'wp-backend',
+ 'utm_medium' => 'admin-bar',
+ 'utm_content' => 'settings_dashboard_link',
+ ),
+ 'settings_email_existence_alert' => array(
+ 'utm_id' => '',
+ 'utm_term' => '',
+ 'utm_source' => 'admin_panel',
+ 'utm_medium' => 'settings',
+ 'utm_content' => 'email_existence_alert',
+ ),
+ 'settings_hoster_api_key' => array(
+ 'utm_id' => '',
+ 'utm_term' => '',
+ 'utm_source' => 'admin_panel',
+ 'utm_medium' => 'settings',
+ 'utm_content' => 'hoster_api_key',
+ ),
+ 'settings_cloud_dashboard_button' => array(
+ 'utm_id' => '',
+ 'utm_term' => '',
+ 'utm_source' => 'admin_panel',
+ 'utm_medium' => 'settings',
+ 'utm_content' => 'cloud_dashboard_button',
+ ),
+ 'blog_email_validation_status' => array(
+ 'utm_id' => '',
+ 'utm_term' => '',
+ 'utm_source' => 'admin_panel',
+ 'utm_medium' => 'settings',
+ 'utm_content' => 'email_validation_status',
+ ),
+ 'settings_public_offer' => array(
+ 'utm_id' => '',
+ 'utm_term' => '',
+ 'utm_source' => 'admin_panel',
+ 'utm_medium' => 'settings',
+ 'utm_content' => 'public_offer',
+ ),
);
public static function buildCleanTalkLink($utm_preset, $uri = '', $get_params = array(), $domain = 'https://cleantalk.org')
diff --git a/lib/Cleantalk/ApbctWP/RateLimit/ApbctRateLimiter.php b/lib/Cleantalk/ApbctWP/RateLimit/ApbctRateLimiter.php
new file mode 100644
index 000000000..a5605c798
--- /dev/null
+++ b/lib/Cleantalk/ApbctWP/RateLimit/ApbctRateLimiter.php
@@ -0,0 +1,178 @@
+ip = (string) Helper::ipGet('remote_addr', true);
+ }
+
+ /**
+ * Sets the User-Agent from server variables
+ *
+ * @return void
+ */
+ protected function setUA(): void
+ {
+ $this->ua = Server::getString('HTTP_USER_AGENT');
+ }
+
+ /**
+ * Override UID to be per-IP only (ignoring UA),
+ * so an attacker cannot bypass the limit by rotating User-Agent.
+ *
+ * @return void
+ */
+ protected function setUID(): void
+ {
+ $this->uid = md5($this->ip . $this->config->type);
+ }
+
+ /**
+ * Performs health check to ensure database table exists and config is valid
+ *
+ * @return bool
+ */
+ protected function healthCheck(): bool
+ {
+ global $wpdb;
+
+ if ( ! defined('APBCT_TBL_RATE_LIMITS') ) {
+ return false;
+ }
+
+ $table_name = APBCT_TBL_RATE_LIMITS;
+ $sql = $wpdb->prepare('SHOW TABLES LIKE %s', $table_name);
+ $table_ok = ! empty($wpdb->get_var($sql));
+
+ return $table_ok && parent::healthCheck();
+ }
+
+ /**
+ * Handles rate limiter errors (silent fail - allow request through)
+ *
+ * @param string $msg Error message
+ * @return void
+ */
+ protected function handleErrors(string $msg): void
+ {
+ // Silent fail - errors are handled by process_ok flag in the caller
+ }
+
+ /**
+ * Retrieves rate limit data for the current UID from database
+ *
+ * @return RateLimiterDto|false
+ */
+ protected function selectUIDData()
+ {
+ global $wpdb;
+
+ $table_name = APBCT_TBL_RATE_LIMITS;
+
+ $sql = $wpdb->prepare(
+ "SELECT uid, type, ip, ua, counter, last_call, created_at FROM {$table_name} WHERE uid = %s LIMIT 1",
+ $this->uid
+ );
+
+ $result = $wpdb->get_row($sql, ARRAY_A);
+
+ return ! empty($result) ? new RateLimiterDto($result) : false;
+ }
+
+ /**
+ * Inserts a new rate limit record
+ *
+ * @param RateLimiterDto $uid_data
+ * @return bool
+ */
+ protected function insert(RateLimiterDto $uid_data): bool
+ {
+ global $wpdb;
+
+ $table_name = APBCT_TBL_RATE_LIMITS;
+
+ $sql = $wpdb->prepare(
+ "INSERT INTO {$table_name} (uid, type, ip, ua, counter, last_call, created_at)
+ VALUES (%s, %s, %s, %s, 1, %d, %d)
+ ON DUPLICATE KEY UPDATE last_call = %d, counter = counter + 1",
+ $uid_data->uid,
+ $uid_data->type,
+ $uid_data->ip,
+ $uid_data->ua,
+ $uid_data->last_call,
+ $uid_data->created_at,
+ $uid_data->last_call
+ );
+
+ return false !== $wpdb->query($sql);
+ }
+
+ /**
+ * Increments the counter for an existing rate limit record.
+ * Resets counter if the period has expired.
+ *
+ * @param RateLimiterDto $uid_data
+ * @return bool
+ */
+ protected function increment(RateLimiterDto $uid_data): bool
+ {
+ global $wpdb;
+
+ $table_name = APBCT_TBL_RATE_LIMITS;
+
+ $is_expired = ($this->current_ts - $uid_data->created_at) > $this->config->period;
+
+ $uid_data->counter = $is_expired ? 1 : $uid_data->counter + 1;
+ $uid_data->created_at = $is_expired ? $this->current_ts : $uid_data->created_at;
+ $uid_data->last_call = $this->current_ts;
+
+ $sql = $wpdb->prepare(
+ "UPDATE {$table_name} SET counter = %d, last_call = %d, created_at = %d WHERE uid = %s",
+ $uid_data->counter,
+ $uid_data->last_call,
+ $uid_data->created_at,
+ $uid_data->uid
+ );
+
+ return false !== $wpdb->query($sql);
+ }
+
+ /**
+ * Removes expired rate limit records from the database
+ *
+ * @return bool
+ */
+ protected function cleanUp(): bool
+ {
+ global $wpdb;
+
+ $table_name = APBCT_TBL_RATE_LIMITS;
+ $threshold = $this->current_ts - ($this->config->period + 10);
+
+ $sql = $wpdb->prepare(
+ "DELETE FROM {$table_name} WHERE created_at < %d AND type = %s",
+ $threshold,
+ $this->config->type
+ );
+
+ return false !== $wpdb->query($sql);
+ }
+}
diff --git a/lib/Cleantalk/ApbctWP/RemoteCalls.php b/lib/Cleantalk/ApbctWP/RemoteCalls.php
index 363e49c64..3821d9e9f 100644
--- a/lib/Cleantalk/ApbctWP/RemoteCalls.php
+++ b/lib/Cleantalk/ApbctWP/RemoteCalls.php
@@ -3,10 +3,11 @@
namespace Cleantalk\ApbctWP;
use Cleantalk\ApbctWP\Firewall\SFWUpdateHelper;
-use Cleantalk\ApbctWP\UpdatePlugin\DbAnalyzer;
+use Cleantalk\ApbctWP\RateLimit\ApbctRateLimiter;
use Cleantalk\ApbctWP\Variables\Post;
use Cleantalk\ApbctWP\Variables\Request;
use Cleantalk\ApbctWP\Variables\Get;
+use Cleantalk\Common\RateLimiter\RateLimiterConfig;
use Cleantalk\Common\TT;
class RemoteCalls
@@ -102,6 +103,11 @@ public static function perform()
$action = strtolower(Request::getString('spbc_remote_call_action'));
$token = strtolower(Request::getString('spbc_remote_call_token'));
+ // Rate limit check — block abusive IPs before any cooldown logic
+ if ( ! self::rateLimitCheck() ) {
+ die('FAIL ' . json_encode(array('error' => 'RATE_LIMIT_EXCEEDED')));
+ }
+
if ( isset($apbct->remote_calls[$action]) ) {
$cooldown = isset($apbct->remote_calls[$action]['cooldown']) ? $apbct->remote_calls[$action]['cooldown'] : self::COOLDOWN;
@@ -114,9 +120,6 @@ public static function perform()
time() - $apbct->remote_calls[$action]['last_call'] >= $cooldown ||
($action === 'sfw_update' && Request::get('file_urls'))
) {
- $apbct->remote_calls[$action]['last_call'] = time();
- $apbct->save('remote_calls');
-
if ( ! self::isRcAllowed() ) {
die('FAIL ' . json_encode(array('error' => 'FORBIDDEN')));
}
@@ -126,6 +129,10 @@ public static function perform()
(self::checkToken($token)) ||
(self::isAllowedWithoutToken($action) && self::checkWithoutToken())
) {
+ // Update last_call only for authorized requests
+ $apbct->remote_calls[$action]['last_call'] = time();
+ $apbct->save('remote_calls');
+
// Flag to let plugin know that Remote Call is running.
$apbct->rc_running = true;
@@ -677,4 +684,23 @@ private static function hideSensitiveData($data)
}
return $data;
}
+
+ /**
+ * Rate limit check for remote calls.
+ * Blocks abusive IPs that exceed 10 requests per 60 seconds.
+ *
+ * @return bool True if request is allowed, false if rate limited
+ */
+ private static function rateLimitCheck()
+ {
+ $config = new RateLimiterConfig('rc_remote_call', 10, 60);
+ $limiter = new ApbctRateLimiter($config);
+
+ // If rate limiter failed (e.g. table missing), allow the request through
+ if ( ! $limiter->process_ok ) {
+ return true;
+ }
+
+ return $limiter->checkPassed();
+ }
}
diff --git a/lib/Cleantalk/ApbctWP/State.php b/lib/Cleantalk/ApbctWP/State.php
index 3d3e859c0..4c2fc7e97 100644
--- a/lib/Cleantalk/ApbctWP/State.php
+++ b/lib/Cleantalk/ApbctWP/State.php
@@ -531,6 +531,10 @@ protected function setDefinitions()
// Table with session data.
define('APBCT_SPAMSCAN_LOGS', $db_prefix . 'cleantalk_spamscan_logs');
}
+ if ( ! defined('APBCT_TBL_RATE_LIMITS')) {
+ // Table with rate limit data.
+ define('APBCT_TBL_RATE_LIMITS', $db_prefix . 'cleantalk_rate_limits');
+ }
if ( ! defined('APBCT_SELECT_LIMIT')) {
// Select limit for logs.
define('APBCT_SELECT_LIMIT', 5000);
diff --git a/lib/Cleantalk/ApbctWP/UpdatePlugin/DbColumnCreator.php b/lib/Cleantalk/ApbctWP/UpdatePlugin/DbColumnCreator.php
index 46290174f..3ae812be2 100644
--- a/lib/Cleantalk/ApbctWP/UpdatePlugin/DbColumnCreator.php
+++ b/lib/Cleantalk/ApbctWP/UpdatePlugin/DbColumnCreator.php
@@ -127,36 +127,42 @@ private function updateIndexes($schema_indexes_raw, $db_column_names, &$errors)
$db_indexes_raw = $wpdb->get_results("SHOW INDEX FROM `$this->dbTableName`", ARRAY_A);
if (is_string($schema_indexes_raw) && !empty($schema_indexes_raw)) {
- // Parse index definitions from string like: "PRIMARY KEY (`id`), INDEX ( `network` , `mask` ), INDEX ( `status` )"
- preg_match_all('/(?:PRIMARY\s+KEY|INDEX)\s*\([^)]+\)/i', $schema_indexes_raw, $matches);
- if (isset($matches[0]) && !empty($matches[0])) {
- foreach ($matches[0] as $match) {
- // Extract column names from index definition
- if (preg_match('/\(([^)]+)\)/', $match, $col_match) && isset($col_match[1])) {
- $columns_raw = trim($col_match[1]);
- $columns = preg_split('/\s*,\s*/', $columns_raw);
- // Normalize column names
- $normalized_columns = array();
- foreach ($columns as $col) {
- $normalized_columns[] = trim($col, '` ');
- }
+ // Parse index definitions from string like: "PRIMARY KEY (`id`), UNIQUE KEY `uid` (`uid`), INDEX (`type`)"
+ preg_match_all('/(PRIMARY\s+KEY|UNIQUE\s+(?:KEY|INDEX)|INDEX|KEY)\s*(?:`?(\w+)`?\s*)?\(([^)]+)\)/i', $schema_indexes_raw, $matches, PREG_SET_ORDER);
+ if (!empty($matches)) {
+ foreach ($matches as $match) {
+ if (!isset($match[1], $match[3])) {
+ continue;
+ }
+ $keyword = strtoupper(trim($match[1]));
+ $explicit_name = !empty($match[2]) ? $match[2] : '';
+ $columns_raw = trim($match[3]);
- // Skip PRIMARY KEY as it should already exist if table has primary key
- if (stripos($match, 'PRIMARY') !== false) {
- continue;
- }
+ // Skip PRIMARY KEY as it should already exist if table has primary key
+ if (strpos($keyword, 'PRIMARY') !== false) {
+ continue;
+ }
- if (!empty($normalized_columns)) {
- $index_name = $normalized_columns[0];
+ $columns = preg_split('/\s*,\s*/', $columns_raw);
+ // Normalize column names
+ $normalized_columns = array();
+ foreach ($columns as $col) {
+ $normalized_columns[] = trim($col, '` ');
+ }
- $normalized_def = 'INDEX (' . implode(',', array_map(function ($col) {
- return '`' . $col . '`';
- }, $normalized_columns)) . ')';
+ if (!empty($normalized_columns)) {
+ // Use explicit name if provided, otherwise first column name
+ $index_name = !empty($explicit_name) ? $explicit_name : $normalized_columns[0];
- // Use first column as index name
- $schema_indexes[$index_name] = $normalized_def;
- $schema_index_names[] = $index_name;
- }
+ $is_unique = (strpos($keyword, 'UNIQUE') !== false);
+ $index_type = $is_unique ? 'UNIQUE INDEX' : 'INDEX';
+
+ $normalized_def = $index_type . ' (' . implode(',', array_map(function ($col) {
+ return '`' . $col . '`';
+ }, $normalized_columns)) . ')';
+
+ $schema_indexes[$index_name] = $normalized_def;
+ $schema_index_names[] = $index_name;
}
}
}
@@ -251,7 +257,8 @@ private function updateIndexes($schema_indexes_raw, $db_column_names, &$errors)
$index_def = $schema_indexes[$index_name];
if (preg_match('/\(([^)]+)\)/', $index_def, $col_match) && isset($col_match[1])) {
$columns = trim($col_match[1]);
- $sql = "ALTER TABLE `$this->dbTableName` ADD INDEX `$index_name` ($columns)";
+ $add_type = (stripos($index_def, 'UNIQUE') !== false) ? 'UNIQUE INDEX' : 'INDEX';
+ $sql = "ALTER TABLE `$this->dbTableName` ADD $add_type `$index_name` ($columns)";
$result = $wpdb->query($sql);
if ($result === false) {
$errors[] = "Failed to recreate index `$index_name`.\nQuery: $wpdb->last_query\nError: $wpdb->last_error";
@@ -287,7 +294,8 @@ private function updateIndexes($schema_indexes_raw, $db_column_names, &$errors)
// Parse the index definition to extract columns
if (preg_match('/\(([^)]+)\)/', $index_def, $col_match) && isset($col_match[1])) {
$columns = trim($col_match[1]);
- $sql = "ALTER TABLE `$this->dbTableName` ADD INDEX `$diff_index_name` ($columns)";
+ $add_type = (stripos($index_def, 'UNIQUE') !== false) ? 'UNIQUE INDEX' : 'INDEX';
+ $sql = "ALTER TABLE `$this->dbTableName` ADD $add_type `$diff_index_name` ($columns)";
$result = $wpdb->query($sql);
if ($result === false) {
$errors[] = "Failed to add index `$diff_index_name`.\nQuery: $wpdb->last_query\nError: $wpdb->last_error";
diff --git a/lib/Cleantalk/ApbctWP/WcFieldSwapHoneypot.php b/lib/Cleantalk/ApbctWP/WcFieldSwapHoneypot.php
new file mode 100644
index 000000000..c902d90b6
--- /dev/null
+++ b/lib/Cleantalk/ApbctWP/WcFieldSwapHoneypot.php
@@ -0,0 +1,303 @@
+settings['data__honeypot_field'])
+ && ! apbct_exclusions_check__url()
+ && ! apbct_is_amp_request();
+ }
+
+ /**
+ * @param array $fields
+ * @return array
+ * @psalm-suppress PossiblyUnusedReturnValue
+ */
+ public static function swapCheckoutFields($fields)
+ {
+ if ( ! self::isActive() || self::isCheckoutBlocksPage() ) {
+ return $fields;
+ }
+
+ if ( empty($fields['billing'][self::FIELD_BILLING_EMAIL]) ) {
+ return $fields;
+ }
+
+ $secret = self::getSecretName(self::FIELD_BILLING_EMAIL);
+
+ $fields['billing'][$secret] = $fields['billing'][self::FIELD_BILLING_EMAIL];
+ $fields['billing'][$secret]['id'] = $secret;
+ unset($fields['billing'][self::FIELD_BILLING_EMAIL]);
+
+ return $fields;
+ }
+
+ public static function renderTrapAndScript()
+ {
+ if ( ! self::isActive() || self::isCheckoutBlocksPage() ) {
+ return;
+ }
+
+ echo self::renderTrapField(self::FIELD_BILLING_EMAIL, 'email');
+ echo self::renderIdSwapScript(self::FIELD_BILLING_EMAIL);
+ }
+
+ /**
+ * @param string $canonical_name
+ * @param string $input_type
+ * @return string
+ */
+ public static function renderTrapField($canonical_name, $input_type)
+ {
+ $trap_style = 'padding:0 !important;clip:rect(1px, 1px, 1px, 1px) !important;'
+ . 'position:absolute !important;white-space:nowrap !important;'
+ . 'height:1px !important;width:1px !important;overflow:hidden !important;';
+
+ return sprintf(
+ ' ',
+ esc_attr($input_type),
+ esc_attr($canonical_name),
+ esc_attr($canonical_name),
+ esc_attr($trap_style)
+ );
+ }
+
+ /**
+ * @param string $field_key
+ * @return string
+ */
+ public static function renderIdSwapScript($field_key)
+ {
+ $secret = self::getSecretName($field_key);
+ $random_id = 'a' . substr(md5((string) time()), 0, 31);
+
+ return '';
+ }
+
+ /**
+ * Stores honeypot status without changing posted data keys — WC validates by secret field name.
+ *
+ * @param array $data
+ * @return array
+ * @psalm-suppress PossiblyUnusedReturnValue
+ */
+ public static function captureCheckoutResult($data)
+ {
+ if ( ! self::isActive() || ! self::isCheckoutSubmitRequest() ) {
+ return $data;
+ }
+
+ self::$checkout_result = self::evaluateFromPostedData(self::getRawPostedData());
+
+ return $data;
+ }
+
+ /**
+ * Adds billing_email for CleanTalk field parsing; does not alter keys used by WooCommerce.
+ *
+ * @param array $input
+ * @return array
+ */
+ public static function enrichInputArrayForCleanTalk(array $input)
+ {
+ if ( ! self::isActive() || ! self::isCheckoutSubmitRequest() ) {
+ return $input;
+ }
+
+ $secret = self::getSecretName(self::FIELD_BILLING_EMAIL);
+
+ if ( empty($input[$secret]) ) {
+ return $input;
+ }
+
+ if ( empty($input[self::FIELD_BILLING_EMAIL]) ) {
+ $input[self::FIELD_BILLING_EMAIL] = $input[$secret];
+ }
+
+ return $input;
+ }
+
+ /**
+ * @return array{status: int|null, value: string|null, source: string|null}
+ */
+ public static function getCheckoutResult()
+ {
+ if ( self::$checkout_result !== null ) {
+ return self::$checkout_result;
+ }
+
+ if ( ! self::isActive() || ! self::isCheckoutSubmitRequest() ) {
+ return self::emptyResult();
+ }
+
+ self::$checkout_result = self::evaluateFromPostedData(self::getRawPostedData());
+
+ return self::$checkout_result;
+ }
+
+ /**
+ * Raw POST including trap field (not present in WC checkout posted_data).
+ *
+ * @return array
+ */
+ private static function getRawPostedData()
+ {
+ if ( empty($_POST) || ! is_array($_POST) ) {
+ return array();
+ }
+
+ $post = wp_unslash($_POST);
+
+ return is_array($post) ? $post : array();
+ }
+
+ /**
+ * @param array $data
+ * @return array{status: int|null, value: string|null, source: string|null}
+ */
+ public static function evaluateFromPostedData(array $data)
+ {
+ $field_key = self::FIELD_BILLING_EMAIL;
+ $secret = self::getSecretName($field_key);
+
+ $trap_present = array_key_exists($field_key, $data);
+ $secret_present = array_key_exists($secret, $data);
+
+ if ( ! $trap_present && ! $secret_present ) {
+ return self::emptyResult();
+ }
+
+ $trap_value = '';
+ if ( $trap_present && isset($data[$field_key]) ) {
+ $trap_value = trim((string) $data[$field_key]);
+ }
+
+ if ( $trap_value !== '' ) {
+ return array(
+ 'status' => 0,
+ 'value' => $trap_value,
+ 'source' => $field_key,
+ );
+ }
+
+ return array(
+ 'status' => 1,
+ 'value' => null,
+ 'source' => $field_key,
+ );
+ }
+
+ /**
+ * @param string $field_key
+ * @return string
+ */
+ public static function getSecretName($field_key)
+ {
+ $salt = defined('NONCE_SALT') ? NONCE_SALT : ABSPATH;
+ $secret = substr(sha1(md5('apbct-wc-' . $field_key . $salt)), 0, 10);
+ $secret = self::ensureSecretStartsWithLetter($secret);
+
+ return $secret;
+ }
+
+ /**
+ * @param string $secret
+ * @return string
+ */
+ private static function ensureSecretStartsWithLetter($secret)
+ {
+ $first_char = substr($secret, 0, 1);
+
+ if ( is_numeric($first_char) ) {
+ return chr((int) $first_char + 97) . substr($secret, 1);
+ }
+
+ return $secret;
+ }
+
+ /**
+ * @return array{status: int|null, value: string|null, source: string|null}
+ */
+ private static function emptyResult()
+ {
+ return array(
+ 'status' => null,
+ 'value' => null,
+ 'source' => null,
+ );
+ }
+
+ private static function isCheckoutSubmitRequest()
+ {
+ if ( apbct_is_rest() ) {
+ return false;
+ }
+
+ if ( Request::get('wc-ajax') === 'checkout' ) {
+ return true;
+ }
+
+ if ( empty($_POST) ) {
+ return false;
+ }
+
+ $secret = self::getSecretName(self::FIELD_BILLING_EMAIL);
+
+ return isset($_POST['woocommerce_checkout_place_order'])
+ || isset($_POST[self::FIELD_BILLING_EMAIL])
+ || isset($_POST[$secret]);
+ }
+
+ private static function isCheckoutBlocksPage()
+ {
+ if ( ! function_exists('is_checkout') || ! is_checkout() ) {
+ return false;
+ }
+
+ global $post;
+
+ if ( ! $post || ! function_exists('has_block') ) {
+ return false;
+ }
+
+ return has_block('woocommerce/checkout', $post);
+ }
+}
diff --git a/lib/Cleantalk/Common/HTTP/Request.php b/lib/Cleantalk/Common/HTTP/Request.php
index b7e7db7c9..ee16ab029 100644
--- a/lib/Cleantalk/Common/HTTP/Request.php
+++ b/lib/Cleantalk/Common/HTTP/Request.php
@@ -366,8 +366,8 @@ protected function runCallbacks()
}
unset($response);
- // Return a single content if it was a single request
- return is_array($this->response) && count($this->response) > 1
+ // Return associative array if URLs were passed as array, single value otherwise
+ return is_array($this->url)
? $return_value
: reset($return_value);
}
diff --git a/lib/Cleantalk/Common/RateLimiter/.gitignore b/lib/Cleantalk/Common/RateLimiter/.gitignore
new file mode 100644
index 000000000..cc341b5c2
--- /dev/null
+++ b/lib/Cleantalk/Common/RateLimiter/.gitignore
@@ -0,0 +1,4 @@
+.idea
+/vendor/
+/composer.lock
+/tests/.phpunit.result.cache
diff --git a/lib/Cleantalk/Common/RateLimiter/README.md b/lib/Cleantalk/Common/RateLimiter/README.md
new file mode 100644
index 000000000..5ab804db9
--- /dev/null
+++ b/lib/Cleantalk/Common/RateLimiter/README.md
@@ -0,0 +1 @@
+How to use this app here
diff --git a/lib/Cleantalk/Common/RateLimiter/RateLimiter.php b/lib/Cleantalk/Common/RateLimiter/RateLimiter.php
new file mode 100644
index 000000000..7f731f246
--- /dev/null
+++ b/lib/Cleantalk/Common/RateLimiter/RateLimiter.php
@@ -0,0 +1,227 @@
+config = $config;
+ $this->current_ts = time();
+
+ $this->setIP();
+ $this->setUA();
+ $this->setUID();
+ }
+
+ /**
+ * Sets the unique identifier for the current request
+ * Must be implemented by child classes
+ *
+ * @return void
+ */
+ protected function setUID(): void
+ {
+ $this->uid = md5($this->ip . $this->ua . $this->config->type);
+ }
+
+ /**
+ * Determines if the current request should be rate limited
+ *
+ * @return bool True if request should be allowed or error occurred, false if rate limited
+ */
+ public function checkPassed(): bool
+ {
+ try {
+ if (!$this->healthCheck()) {
+ throw new \Exception('HEALTH_CHECK_FAILED');
+ }
+
+ if (!$this->cleanUp()) {
+ throw new \Exception('CLEANUP_FAILED');
+ }
+
+ $uid_data = $this->selectUIDData();
+ $record_found = false !== $uid_data;
+
+ if ($record_found && !$uid_data->data_ok) {
+ throw new \Exception('UID_DATA_INVALID');
+ }
+
+ if ($record_found && $this->isLocked($uid_data)) {
+ return false; // Block here by limit exceeded
+ }
+
+ if ($record_found) {
+ if (!$this->increment($uid_data)) {
+ throw new \Exception('INCREMENT_FAILED');
+ }
+ } else {
+ $uid_data = new RateLimiterDto(
+ array(
+ 'uid' => $this->uid,
+ 'type' => $this->config->type,
+ 'counter' => 1,
+ 'last_call' => $this->current_ts,
+ 'created_at' => $this->current_ts,
+ 'ip' => $this->ip,
+ 'ua' => $this->ua,
+ )
+ );
+ if (!$uid_data->data_ok) {
+ throw new \Exception('UID_DATA_INVALID__INSERT');
+ }
+ if (!$this->insert($uid_data)) {
+ throw new \Exception('INSERT_FAILED');
+ }
+ }
+ } catch (\Exception $e) {
+ $this->process_ok = false;
+ $this->handleErrors($e->getMessage());
+ return true;
+ }
+
+ return true;
+ }
+
+ /**
+ * Checks if the current UID has exceeded the rate limit
+ *
+ * @param RateLimiterDto $uid_data
+ * @return bool True if rate limited, false otherwise
+ */
+ protected function isLocked(RateLimiterDto $uid_data): bool
+ {
+ return $uid_data->data_ok && ($uid_data->counter > $this->config->limit);
+ }
+
+ /**
+ * Performs basic health check on configuration
+ *
+ * @return bool True if configuration is valid, false otherwise
+ */
+ protected function healthCheck(): bool
+ {
+ if ($this->config->limit < 1) {
+ return false;
+ }
+ if ($this->config->period < 1) {
+ return false;
+ }
+ if ($this->config->type === null) {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Retrieves rate limit data for the current UID
+ * Default implementation returns empty data
+ *
+ * @return RateLimiterDto|false Rate limit data or false if not found
+ */
+ protected function selectUIDData()
+ {
+ return new RateLimiterDto(array());
+ }
+
+ /**
+ * Sets the IP address for the current request
+ * Must be implemented by child classes
+ *
+ * @return void
+ */
+ abstract protected function setIP(): void;
+
+ /**
+ * Sets the IP address for the current request
+ * Must be implemented by child classes
+ *
+ * @return void
+ */
+ abstract protected function setUA(): void;
+
+ /**
+ * Handles errors that occur during rate limiting
+ * Must be implemented by child classes
+ *
+ * @param string $msg Error message
+ * @return void
+ */
+ abstract protected function handleErrors(string $msg): void;
+
+ /**
+ * Increments the counter for an existing rate limit record
+ * Must be implemented by child classes
+ * @param RateLimiterDto $uid_data
+ * @return bool True on success, false on failure
+ */
+ abstract protected function increment(RateLimiterDto $uid_data): bool;
+
+ /**
+ * Inserts a new rate limit record
+ * Must be implemented by child classes
+ * @param RateLimiterDto $uid_data
+ * @return bool True on success, false on failure
+ */
+ abstract protected function insert(RateLimiterDto $uid_data): bool;
+
+ /**
+ * Cleans up expired rate limit records
+ * Must be implemented by child classes
+ *
+ * @return bool True on success, false on failure
+ */
+ abstract protected function cleanUp(): bool;
+}
diff --git a/lib/Cleantalk/Common/RateLimiter/RateLimiterConfig.php b/lib/Cleantalk/Common/RateLimiter/RateLimiterConfig.php
new file mode 100644
index 000000000..93f673b38
--- /dev/null
+++ b/lib/Cleantalk/Common/RateLimiter/RateLimiterConfig.php
@@ -0,0 +1,50 @@
+type = $type;
+ $this->limit = $limit;
+ $this->period = $period;
+ }
+}
diff --git a/lib/Cleantalk/Common/RateLimiter/RateLimiterDto.php b/lib/Cleantalk/Common/RateLimiter/RateLimiterDto.php
new file mode 100644
index 000000000..59a83726b
--- /dev/null
+++ b/lib/Cleantalk/Common/RateLimiter/RateLimiterDto.php
@@ -0,0 +1,94 @@
+data_ok = true;
+ } catch (\Exception $_e) {
+ $this->data_ok = false;
+ }
+ }
+}
diff --git a/lib/Cleantalk/Common/RateLimiter/composer.json b/lib/Cleantalk/Common/RateLimiter/composer.json
new file mode 100644
index 000000000..422de8331
--- /dev/null
+++ b/lib/Cleantalk/Common/RateLimiter/composer.json
@@ -0,0 +1,39 @@
+{
+ "name": "cleantalk/rate-limiter",
+ "description": "CleanTalk APBCT RateLimit classes",
+ "authors": [
+ {
+ "name": "CleanTalk Team",
+ "email": "support@cleantalk.org"
+ }
+ ],
+ "license": "GPL-3.0-or-later",
+ "autoload": {
+ "psr-4": {
+ "Cleantalk\\Common\\RateLimiter\\": "./"
+ }
+ },
+ "require": {
+ "cleantalk/templates": "*"
+ },
+ "require-dev": {
+ "vimeo/psalm": "^4.8",
+ "phpunit/phpunit": "^8.5.52",
+ "squizlabs/php_codesniffer": "3.*",
+ "phpcompatibility/php-compatibility": "^9.3"
+ },
+ "config": {
+ "allow-plugins": {
+ "dealerdirect/phpcodesniffer-composer-installer": true
+ }
+ },
+ "scripts": {
+ "test": [
+ "vendor/bin/phpunit --configuration tests/phpunit.xml",
+ "vendor/bin/phpcs --config-set installed_paths vendor/phpcompatibility/php-compatibility",
+ "vendor/bin/phpcs --standard=tests/.phpcs.xml",
+ "vendor/bin/psalm --no-cache --config=tests/psalm.xml",
+ "vendor/bin/psalm --no-cache --config=tests/psalm.xml --taint-analysis"
+ ]
+ }
+}
diff --git a/lib/Cleantalk/Common/RateLimiter/tests/.phpcs.xml b/lib/Cleantalk/Common/RateLimiter/tests/.phpcs.xml
new file mode 100644
index 000000000..ca06694be
--- /dev/null
+++ b/lib/Cleantalk/Common/RateLimiter/tests/.phpcs.xml
@@ -0,0 +1,30 @@
+
+
+ Sniff code to check different PHP compatibility an PSR-12 Code Style
+
+
+ ../
+
+
+ lib/*
+ tests/*
+ vendor/*
+ node_modules/*
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/lib/Cleantalk/Common/RateLimiter/tests/phpunit.xml b/lib/Cleantalk/Common/RateLimiter/tests/phpunit.xml
new file mode 100644
index 000000000..df01333e0
--- /dev/null
+++ b/lib/Cleantalk/Common/RateLimiter/tests/phpunit.xml
@@ -0,0 +1,16 @@
+
+
+
+
+ ./
+ ./bootstrap.php
+
+
+
diff --git a/lib/Cleantalk/Common/RateLimiter/tests/psalm.xml b/lib/Cleantalk/Common/RateLimiter/tests/psalm.xml
new file mode 100644
index 000000000..28a40a3e9
--- /dev/null
+++ b/lib/Cleantalk/Common/RateLimiter/tests/psalm.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/lib/Cleantalk/Common/Schema.php b/lib/Cleantalk/Common/Schema.php
index 0151d006a..0d2788b7a 100644
--- a/lib/Cleantalk/Common/Schema.php
+++ b/lib/Cleantalk/Common/Schema.php
@@ -100,6 +100,18 @@ class Schema
'__indexes' => 'PRIMARY KEY (`id`)',
'__createkey' => 'INT unsigned primary KEY AUTO_INCREMENT FIRST'
),
+ 'rate_limits' => array(
+ 'id' => 'INT NOT NULL AUTO_INCREMENT',
+ 'uid' => 'VARCHAR(32) NOT NULL',
+ 'type' => 'VARCHAR(64) NOT NULL',
+ 'ip' => 'VARCHAR(45) NOT NULL',
+ 'ua' => 'VARCHAR(512) NOT NULL DEFAULT \'\'',
+ 'counter' => 'INT NOT NULL DEFAULT 0',
+ 'last_call' => 'INT NOT NULL DEFAULT 0',
+ 'created_at' => 'INT NOT NULL DEFAULT 0',
+ '__indexes' => 'PRIMARY KEY (`id`), UNIQUE KEY `uid` (`uid`), INDEX (`type`), INDEX (`created_at`)',
+ '__createkey' => 'INT unsigned primary KEY AUTO_INCREMENT FIRST'
+ ),
);
/**
diff --git a/lib/Cleantalk/Common/Templates/.gitignore b/lib/Cleantalk/Common/Templates/.gitignore
new file mode 100644
index 000000000..cb424410f
--- /dev/null
+++ b/lib/Cleantalk/Common/Templates/.gitignore
@@ -0,0 +1,3 @@
+/.idea/
+/vendor/
+/composer.lock
\ No newline at end of file
diff --git a/lib/Cleantalk/Common/Templates/Dto.php b/lib/Cleantalk/Common/Templates/Dto.php
new file mode 100644
index 000000000..482837b2a
--- /dev/null
+++ b/lib/Cleantalk/Common/Templates/Dto.php
@@ -0,0 +1,59 @@
+isObligatoryParamsPresented($params) ) {
+ throw new \Exception('No go!');
+ }
+
+ foreach ( $params as $param_name => $param ) {
+ if ( property_exists(static::class, $param_name) ) {
+ $type = gettype($this->$param_name);
+ $this->$param_name = $param;
+ settype($this->$param_name, $type);
+ }
+ }
+ }
+
+ /**
+ * @param $params
+ *
+ * @return bool
+ * @since 1.1.0
+ *
+ */
+ private function isObligatoryParamsPresented($params)
+ {
+ return empty($this->obligatory_properties) ||
+ count(array_intersect($this->obligatory_properties, array_keys($params))) === count(
+ $this->obligatory_properties
+ );
+ }
+
+ /**
+ * Get array of all DTO properties as key-value, except obligatory_properties.
+ * @return array
+ */
+ public function getArray()
+ {
+ $array = array();
+ foreach (get_object_vars($this) as $key => $value) {
+ $array[$key] = $value;
+ }
+ unset($array['obligatory_properties']);
+ return $array;
+ }
+}
diff --git a/lib/Cleantalk/Common/Templates/Multiton.php b/lib/Cleantalk/Common/Templates/Multiton.php
new file mode 100644
index 000000000..7fef9b70b
--- /dev/null
+++ b/lib/Cleantalk/Common/Templates/Multiton.php
@@ -0,0 +1,28 @@
+init($params);
+ }
+
+ return static::$instances[$instance];
+ }
+}
diff --git a/lib/Cleantalk/Common/Templates/Singleton.php b/lib/Cleantalk/Common/Templates/Singleton.php
new file mode 100644
index 000000000..e21581a72
--- /dev/null
+++ b/lib/Cleantalk/Common/Templates/Singleton.php
@@ -0,0 +1,47 @@
+init($params);
+ }
+ return static::$instance;
+ }
+
+ /**
+ * Alternative constructor
+ */
+ protected function init($params = array())
+ {
+ }
+
+ public static function resetInstance()
+ {
+ static::$instance = null;
+ }
+ }
+}
diff --git a/lib/Cleantalk/Common/Templates/composer.json b/lib/Cleantalk/Common/Templates/composer.json
new file mode 100644
index 000000000..5f406249e
--- /dev/null
+++ b/lib/Cleantalk/Common/Templates/composer.json
@@ -0,0 +1,16 @@
+{
+ "name": "cleantalk/templates",
+ "description": "CleanTalk APBCT traits",
+ "authors": [
+ {
+ "name": "CleanTalk Team",
+ "email": "support@cleantalk.org"
+ }
+ ],
+ "license": "GPL-3.0-or-later",
+ "autoload": {
+ "psr-4": {
+ "Cleantalk\\Common\\Templates\\": "./"
+ }
+ }
+}
diff --git a/psalm.xml b/psalm.xml
index 64d954f0c..ba15ede07 100644
--- a/psalm.xml
+++ b/psalm.xml
@@ -26,6 +26,9 @@
+
+
+
diff --git a/tests/ApbctWP/RateLimit/TestApbctRateLimiter.php b/tests/ApbctWP/RateLimit/TestApbctRateLimiter.php
new file mode 100644
index 000000000..e1079a91c
--- /dev/null
+++ b/tests/ApbctWP/RateLimit/TestApbctRateLimiter.php
@@ -0,0 +1,256 @@
+test_ip = $ip;
+ $this->test_ua = $ua;
+ parent::__construct($config);
+ }
+
+ protected function setIP(): void
+ {
+ $this->ip = $this->test_ip;
+ }
+
+ protected function setUA(): void
+ {
+ $this->ua = $this->test_ua;
+ }
+
+ /**
+ * Same override as ApbctRateLimiter — per-IP only, ignoring UA.
+ */
+ protected function setUID(): void
+ {
+ $this->uid = md5($this->ip . $this->config->type);
+ }
+
+ protected function handleErrors(string $msg): void
+ {
+ }
+
+ protected function increment(RateLimiterDto $uid_data): bool
+ {
+ return true;
+ }
+
+ protected function insert(RateLimiterDto $uid_data): bool
+ {
+ return true;
+ }
+
+ protected function cleanUp(): bool
+ {
+ return true;
+ }
+
+ // --- Expose protected properties for assertions ---
+
+ public function getUID(): string
+ {
+ return $this->uid;
+ }
+
+ public function getIP(): string
+ {
+ return $this->ip;
+ }
+
+ public function getUA(): string
+ {
+ return $this->ua;
+ }
+}
+
+/**
+ * Stub with the DEFAULT base RateLimiter::setUID() (ip + ua + type)
+ * to demonstrate the difference with the per-IP override.
+ */
+class TestRateLimiterBaseStub extends RateLimiter
+{
+ private $test_ip;
+ private $test_ua;
+
+ public function __construct(RateLimiterConfig $config, string $ip, string $ua)
+ {
+ $this->test_ip = $ip;
+ $this->test_ua = $ua;
+ parent::__construct($config);
+ }
+
+ protected function setIP(): void
+ {
+ $this->ip = $this->test_ip;
+ }
+
+ protected function setUA(): void
+ {
+ $this->ua = $this->test_ua;
+ }
+
+ // No setUID override — uses base RateLimiter logic: md5(ip + ua + type)
+
+ protected function handleErrors(string $msg): void
+ {
+ }
+
+ protected function increment(RateLimiterDto $uid_data): bool
+ {
+ return true;
+ }
+
+ protected function insert(RateLimiterDto $uid_data): bool
+ {
+ return true;
+ }
+
+ protected function cleanUp(): bool
+ {
+ return true;
+ }
+
+ public function getUID(): string
+ {
+ return $this->uid;
+ }
+}
+
+class TestApbctRateLimiter extends TestCase
+{
+ /**
+ * @test
+ * UID must equal md5(ip + type), without UA component
+ */
+ public function uidMatchesExpectedHashOfIpAndType()
+ {
+ $config = new RateLimiterConfig('rc_remote_call', 10, 60);
+ $limiter = new TestRateLimiterStub($config, '192.168.1.1', 'Mozilla/5.0');
+
+ $expected = md5('192.168.1.1' . 'rc_remote_call');
+ $this->assertSame($expected, $limiter->getUID());
+ }
+
+ /**
+ * @test
+ * Same IP with different User-Agents MUST produce the same UID (per-IP blocking).
+ * This is the core security fix — attacker cannot bypass rate limit by rotating UA.
+ */
+ public function sameIpDifferentUaProducesSameUid()
+ {
+ $config = new RateLimiterConfig('rc_remote_call', 10, 60);
+
+ $limiter1 = new TestRateLimiterStub($config, '10.0.0.1', 'Mozilla/5.0');
+ $limiter2 = new TestRateLimiterStub($config, '10.0.0.1', 'curl/7.68.0');
+ $limiter3 = new TestRateLimiterStub($config, '10.0.0.1', '');
+
+ $this->assertSame($limiter1->getUID(), $limiter2->getUID());
+ $this->assertSame($limiter1->getUID(), $limiter3->getUID());
+ }
+
+ /**
+ * @test
+ * Different IPs must produce different UIDs — rate limit is per-IP.
+ */
+ public function differentIpsProduceDifferentUids()
+ {
+ $config = new RateLimiterConfig('rc_remote_call', 10, 60);
+
+ $limiter1 = new TestRateLimiterStub($config, '10.0.0.1', 'Mozilla/5.0');
+ $limiter2 = new TestRateLimiterStub($config, '10.0.0.2', 'Mozilla/5.0');
+
+ $this->assertNotSame($limiter1->getUID(), $limiter2->getUID());
+ }
+
+ /**
+ * @test
+ * Different config types produce different UIDs for the same IP.
+ */
+ public function differentTypesProduceDifferentUids()
+ {
+ $config1 = new RateLimiterConfig('rc_remote_call', 10, 60);
+ $config2 = new RateLimiterConfig('login_attempt', 5, 300);
+
+ $limiter1 = new TestRateLimiterStub($config1, '10.0.0.1', 'Mozilla/5.0');
+ $limiter2 = new TestRateLimiterStub($config2, '10.0.0.1', 'Mozilla/5.0');
+
+ $this->assertNotSame($limiter1->getUID(), $limiter2->getUID());
+ }
+
+ /**
+ * @test
+ * Verify that the base RateLimiter (without override) DOES include UA in UID,
+ * while our override does NOT. This proves the override changes behavior.
+ */
+ public function overrideExcludesUaWhileBaseIncludes()
+ {
+ $config = new RateLimiterConfig('rc_remote_call', 10, 60);
+
+ // Base: same IP + different UA → different UID
+ $base1 = new TestRateLimiterBaseStub($config, '10.0.0.1', 'Mozilla/5.0');
+ $base2 = new TestRateLimiterBaseStub($config, '10.0.0.1', 'curl/7.68.0');
+ $this->assertNotSame($base1->getUID(), $base2->getUID(), 'Base UID should include UA');
+
+ // Override: same IP + different UA → SAME UID
+ $override1 = new TestRateLimiterStub($config, '10.0.0.1', 'Mozilla/5.0');
+ $override2 = new TestRateLimiterStub($config, '10.0.0.1', 'curl/7.68.0');
+ $this->assertSame($override1->getUID(), $override2->getUID(), 'Override UID should ignore UA');
+ }
+
+ /**
+ * @test
+ * UID is always a 32-char hex string (MD5 hash).
+ */
+ public function uidIsValidMd5Hash()
+ {
+ $config = new RateLimiterConfig('rc_remote_call', 10, 60);
+ $limiter = new TestRateLimiterStub($config, '192.168.1.100', 'SomeAgent');
+
+ $uid = $limiter->getUID();
+ $this->assertSame(32, strlen($uid));
+ $this->assertRegExp('/^[0-9a-f]{32}$/', $uid);
+ }
+
+ /**
+ * @test
+ * IPv6 address should also work correctly for UID generation.
+ */
+ public function uidWorksWithIpv6()
+ {
+ $config = new RateLimiterConfig('rc_remote_call', 10, 60);
+
+ $limiter1 = new TestRateLimiterStub($config, '::1', 'Mozilla/5.0');
+ $limiter2 = new TestRateLimiterStub($config, '::1', 'curl/7.68.0');
+
+ $expected = md5('::1' . 'rc_remote_call');
+ $this->assertSame($expected, $limiter1->getUID());
+ $this->assertSame($limiter1->getUID(), $limiter2->getUID());
+ }
+
+ /**
+ * @test
+ * Empty IP edge case — should still produce a deterministic UID.
+ */
+ public function uidWithEmptyIp()
+ {
+ $config = new RateLimiterConfig('rc_remote_call', 10, 60);
+
+ $limiter = new TestRateLimiterStub($config, '', 'Mozilla/5.0');
+
+ $expected = md5('' . 'rc_remote_call');
+ $this->assertSame($expected, $limiter->getUID());
+ }
+}
diff --git a/tests/ApbctWP/TestRemoteCalls.php b/tests/ApbctWP/TestRemoteCalls.php
index 5d84ede20..60873d4ca 100644
--- a/tests/ApbctWP/TestRemoteCalls.php
+++ b/tests/ApbctWP/TestRemoteCalls.php
@@ -381,4 +381,99 @@ public function itHidesNestedApiKeyWithUnderscore()
$result['network_settings']['multisite__hoster_api_key']
);
}
+
+ // =========================================================================
+ // Rate limiting for remote calls
+ // =========================================================================
+
+ /** @test */
+ public function rateLimitCheckMethodExists()
+ {
+ $this->assertTrue(
+ method_exists(RemoteCalls::class, 'rateLimitCheck'),
+ 'RemoteCalls must have a rateLimitCheck method'
+ );
+ }
+
+ /** @test */
+ public function rateLimitCheckIsPrivateStatic()
+ {
+ $method = new ReflectionMethod(RemoteCalls::class, 'rateLimitCheck');
+ $this->assertTrue($method->isPrivate(), 'rateLimitCheck must be private');
+ $this->assertTrue($method->isStatic(), 'rateLimitCheck must be static');
+ }
+
+ /** @test */
+ public function rateLimitCheckUsesCorrectConfig()
+ {
+ // Verify the config values used inside rateLimitCheck by inspecting the method body.
+ // The method creates RateLimiterConfig('rc_remote_call', 10, 60).
+ $method = new ReflectionMethod(RemoteCalls::class, 'rateLimitCheck');
+ $method->setAccessible(true);
+
+ // Read the source to confirm config values
+ $filename = $method->getFileName();
+ $startLine = $method->getStartLine();
+ $endLine = $method->getEndLine();
+ $source = implode('', array_slice(file($filename), $startLine - 1, $endLine - $startLine + 1));
+
+ $this->assertStringContainsString("'rc_remote_call'", $source, 'Rate limit type must be rc_remote_call');
+ $this->assertStringContainsString('10', $source, 'Rate limit must be set to 10 requests');
+ $this->assertStringContainsString('60', $source, 'Rate limit period must be 60 seconds');
+ }
+
+ /** @test */
+ public function performCallsRateLimitCheckBeforeCooldown()
+ {
+ // Verify that rateLimitCheck() is called BEFORE any cooldown logic in perform().
+ // Read perform() source and check ordering.
+ $method = new ReflectionMethod(RemoteCalls::class, 'perform');
+ $filename = $method->getFileName();
+ $startLine = $method->getStartLine();
+ $endLine = $method->getEndLine();
+ $source = implode('', array_slice(file($filename), $startLine - 1, $endLine - $startLine + 1));
+
+ $rateLimitPos = strpos($source, 'rateLimitCheck');
+ $cooldownPos = strpos($source, 'last_call');
+
+ $this->assertNotFalse($rateLimitPos, 'perform() must call rateLimitCheck');
+ $this->assertNotFalse($cooldownPos, 'perform() must reference last_call');
+ $this->assertLessThan($cooldownPos, $rateLimitPos, 'rateLimitCheck must be called BEFORE last_call / cooldown logic');
+ }
+
+ /** @test */
+ public function lastCallIsUpdatedAfterTokenCheck()
+ {
+ // Verify that last_call is updated AFTER checkToken / isAllowedWithoutToken,
+ // not before. This prevents attackers from updating cooldown with bogus tokens.
+ $method = new ReflectionMethod(RemoteCalls::class, 'perform');
+ $filename = $method->getFileName();
+ $startLine = $method->getStartLine();
+ $endLine = $method->getEndLine();
+ $source = implode('', array_slice(file($filename), $startLine - 1, $endLine - $startLine + 1));
+
+ $checkTokenPos = strpos($source, 'checkToken');
+ $lastCallUpdate = strpos($source, "['last_call'] = time()");
+
+ $this->assertNotFalse($checkTokenPos, 'perform() must call checkToken');
+ $this->assertNotFalse($lastCallUpdate, 'perform() must update last_call');
+ $this->assertGreaterThan(
+ $checkTokenPos,
+ $lastCallUpdate,
+ 'last_call update must come AFTER token validation'
+ );
+ }
+
+ /** @test */
+ public function performDiesWithRateLimitErrorWhenBlocked()
+ {
+ // Verify the error response format for rate-limited requests
+ $method = new ReflectionMethod(RemoteCalls::class, 'perform');
+ $filename = $method->getFileName();
+ $startLine = $method->getStartLine();
+ $endLine = $method->getEndLine();
+ $source = implode('', array_slice(file($filename), $startLine - 1, $endLine - $startLine + 1));
+
+ $this->assertStringContainsString('RATE_LIMIT_EXCEEDED', $source, 'perform() must use RATE_LIMIT_EXCEEDED error code');
+ }
}
diff --git a/tests/ApbctWP/TestSettingsLongDescriptionLinks.php b/tests/ApbctWP/TestSettingsLongDescriptionLinks.php
new file mode 100644
index 000000000..fd6b218c7
--- /dev/null
+++ b/tests/ApbctWP/TestSettingsLongDescriptionLinks.php
@@ -0,0 +1,643 @@
+assertStringStartsWith('https://cleantalk.org/help/set-cookies-option?', $link);
+ $this->assertStringContainsString('utm_source=apbct_hint_data__set_cookies', $link);
+ $this->assertStringContainsString('utm_medium=WordPress', $link);
+ $this->assertStringContainsString('utm_campaign=ABPCT_Settings', $link);
+ }
+
+ public function testPresetDataSetCookiesExists()
+ {
+ $this->assertArrayHasKey('apbct_hint_data__set_cookies', LinkConstructor::$utm_presets);
+ $preset = LinkConstructor::$utm_presets['apbct_hint_data__set_cookies'];
+ $this->assertSame('apbct_hint_data__set_cookies', $preset['utm_source']);
+ $this->assertSame('WordPress', $preset['utm_medium']);
+ $this->assertSame('ABPCT_Settings', $preset['utm_campaign']);
+ }
+
+ /**
+ * comments__hide_website_field: help/how-to-hide-website-field-in-wordpress-comments
+ * Original: https://cleantalk.org/help/how-to-hide-website-field-in-wordpress-comments?utm_source=apbct_hint_comments__hide_website_field&utm_medium=hide_website_field_hint&utm_campaign=apbct_links
+ */
+ public function testLinkCommentsHideWebsiteField()
+ {
+ $link = LinkConstructor::buildCleanTalkLink(
+ 'apbct_hint_comments__hide_website_field',
+ 'help/how-to-hide-website-field-in-wordpress-comments'
+ );
+
+ $this->assertStringStartsWith('https://cleantalk.org/help/how-to-hide-website-field-in-wordpress-comments?', $link);
+ $this->assertStringContainsString('utm_source=apbct_hint_comments__hide_website_field', $link);
+ $this->assertStringContainsString('utm_medium=hide_website_field_hint', $link);
+ $this->assertStringContainsString('utm_campaign=apbct_links', $link);
+ }
+
+ public function testPresetCommentsHideWebsiteFieldExists()
+ {
+ $this->assertArrayHasKey('apbct_hint_comments__hide_website_field', LinkConstructor::$utm_presets);
+ $preset = LinkConstructor::$utm_presets['apbct_hint_comments__hide_website_field'];
+ $this->assertSame('apbct_hint_comments__hide_website_field', $preset['utm_source']);
+ $this->assertSame('hide_website_field_hint', $preset['utm_medium']);
+ // utm_campaign not in preset — should fall back to static::$utm_campaign = 'apbct_links'
+ $this->assertArrayNotHasKey('utm_campaign', $preset);
+ }
+
+ /**
+ * comments__the_real_person: the-real-person (was already using LinkConstructor)
+ * Original: https://cleantalk.org/the-real-person?...&utm_source=admin_side&utm_medium=trp_badge&utm_content=trp_badge_link_click&utm_campaign=apbct_links
+ */
+ public function testLinkCommentsTheRealPerson()
+ {
+ $link = LinkConstructor::buildCleanTalkLink('trp_learn_more_link', 'the-real-person');
+
+ $this->assertStringStartsWith('https://cleantalk.org/the-real-person?', $link);
+ $this->assertStringContainsString('utm_source=admin_side', $link);
+ $this->assertStringContainsString('utm_medium=trp_badge', $link);
+ $this->assertStringContainsString('utm_content=trp_badge_link_click', $link);
+ $this->assertStringContainsString('utm_campaign=apbct_links', $link);
+ }
+
+ /**
+ * sfw__anti_crawler: help/anti-flood-and-anti-crawler + #anticrawl anchor
+ * Original: https://cleantalk.org/help/anti-flood-and-anti-crawler?utm_source=apbct_hint_sfw__anti_crawler&utm_medium=WordPress&utm_campaign=ABPCT_Settings
+ * Note: #anticrawl is appended outside of buildCleanTalkLink
+ */
+ public function testLinkSfwAntiCrawler()
+ {
+ $link = LinkConstructor::buildCleanTalkLink('apbct_hint_sfw__anti_crawler', 'help/anti-flood-and-anti-crawler');
+
+ $this->assertStringStartsWith('https://cleantalk.org/help/anti-flood-and-anti-crawler?', $link);
+ $this->assertStringContainsString('utm_source=apbct_hint_sfw__anti_crawler', $link);
+ $this->assertStringContainsString('utm_medium=WordPress', $link);
+ $this->assertStringContainsString('utm_campaign=ABPCT_Settings', $link);
+
+ // Verify anchor can be appended
+ $linkWithAnchor = $link . '#anticrawl';
+ $this->assertStringEndsWith('#anticrawl', $linkWithAnchor);
+ }
+
+ public function testPresetSfwAntiCrawlerExists()
+ {
+ $this->assertArrayHasKey('apbct_hint_sfw__anti_crawler', LinkConstructor::$utm_presets);
+ $preset = LinkConstructor::$utm_presets['apbct_hint_sfw__anti_crawler'];
+ $this->assertSame('apbct_hint_sfw__anti_crawler', $preset['utm_source']);
+ $this->assertSame('WordPress', $preset['utm_medium']);
+ $this->assertSame('ABPCT_Settings', $preset['utm_campaign']);
+ }
+
+ /**
+ * sfw__anti_flood: help/anti-flood-and-anti-crawler + #antiflood anchor
+ * Original: https://cleantalk.org/help/anti-flood-and-anti-crawler?utm_source=apbct_hint_sfw__anti_flood&utm_medium=WordPress&utm_campaign=ABPCT_Settings
+ */
+ public function testLinkSfwAntiFlood()
+ {
+ $link = LinkConstructor::buildCleanTalkLink('apbct_hint_sfw__anti_flood', 'help/anti-flood-and-anti-crawler');
+
+ $this->assertStringStartsWith('https://cleantalk.org/help/anti-flood-and-anti-crawler?', $link);
+ $this->assertStringContainsString('utm_source=apbct_hint_sfw__anti_flood', $link);
+ $this->assertStringContainsString('utm_medium=WordPress', $link);
+ $this->assertStringContainsString('utm_campaign=ABPCT_Settings', $link);
+
+ // Verify anchor can be appended
+ $linkWithAnchor = $link . '#antiflood';
+ $this->assertStringEndsWith('#antiflood', $linkWithAnchor);
+ }
+
+ public function testPresetSfwAntiFloodExists()
+ {
+ $this->assertArrayHasKey('apbct_hint_sfw__anti_flood', LinkConstructor::$utm_presets);
+ $preset = LinkConstructor::$utm_presets['apbct_hint_sfw__anti_flood'];
+ $this->assertSame('apbct_hint_sfw__anti_flood', $preset['utm_source']);
+ $this->assertSame('WordPress', $preset['utm_medium']);
+ $this->assertSame('ABPCT_Settings', $preset['utm_campaign']);
+ }
+
+ /**
+ * data__pixel: introducing-cleantalk-pixel (blog.cleantalk.org domain)
+ * Original: https://blog.cleantalk.org/introducing-cleantalk-pixel?utm_source=apbct_hint_data__pixel&utm_medium=WordPress&utm_campaign=ABPCT_Settings
+ */
+ public function testLinkDataPixel()
+ {
+ $link = LinkConstructor::buildCleanTalkLink(
+ 'apbct_hint_data__pixel',
+ 'introducing-cleantalk-pixel',
+ array(),
+ 'https://blog.cleantalk.org'
+ );
+
+ $this->assertStringStartsWith('https://blog.cleantalk.org/introducing-cleantalk-pixel?', $link);
+ $this->assertStringContainsString('utm_source=apbct_hint_data__pixel', $link);
+ $this->assertStringContainsString('utm_medium=WordPress', $link);
+ $this->assertStringContainsString('utm_campaign=ABPCT_Settings', $link);
+ }
+
+ public function testPresetDataPixelExists()
+ {
+ $this->assertArrayHasKey('apbct_hint_data__pixel', LinkConstructor::$utm_presets);
+ $preset = LinkConstructor::$utm_presets['apbct_hint_data__pixel'];
+ $this->assertSame('apbct_hint_data__pixel', $preset['utm_source']);
+ $this->assertSame('WordPress', $preset['utm_medium']);
+ $this->assertSame('ABPCT_Settings', $preset['utm_campaign']);
+ }
+
+ /**
+ * data__honeypot_field: help/honeypot-field
+ * Original: https://cleantalk.org/help/honeypot-field?utm_source=apbct_hint_data__honeypot_field&utm_medium=WordPress&utm_campaign=ABPCT_Settings
+ */
+ public function testLinkDataHoneypotField()
+ {
+ $link = LinkConstructor::buildCleanTalkLink('apbct_hint_data__honeypot_field', 'help/honeypot-field');
+
+ $this->assertStringStartsWith('https://cleantalk.org/help/honeypot-field?', $link);
+ $this->assertStringContainsString('utm_source=apbct_hint_data__honeypot_field', $link);
+ $this->assertStringContainsString('utm_medium=WordPress', $link);
+ $this->assertStringContainsString('utm_campaign=ABPCT_Settings', $link);
+ }
+
+ public function testPresetDataHoneypotFieldExists()
+ {
+ $this->assertArrayHasKey('apbct_hint_data__honeypot_field', LinkConstructor::$utm_presets);
+ $preset = LinkConstructor::$utm_presets['apbct_hint_data__honeypot_field'];
+ $this->assertSame('apbct_hint_data__honeypot_field', $preset['utm_source']);
+ $this->assertSame('WordPress', $preset['utm_medium']);
+ $this->assertSame('ABPCT_Settings', $preset['utm_campaign']);
+ }
+
+ /**
+ * sfw__enabled: help/anti-flood-and-anti-crawler
+ * Original: https://cleantalk.org/help/anti-flood-and-anti-crawler?utm_source=apbct_hint_sfw__enabled&utm_medium=WordPress&utm_campaign=ABPCT_Settings
+ */
+ public function testLinkSfwEnabled()
+ {
+ $link = LinkConstructor::buildCleanTalkLink('apbct_hint_sfw__enabled', 'help/anti-flood-and-anti-crawler');
+
+ $this->assertStringStartsWith('https://cleantalk.org/help/anti-flood-and-anti-crawler?', $link);
+ $this->assertStringContainsString('utm_source=apbct_hint_sfw__enabled', $link);
+ $this->assertStringContainsString('utm_medium=WordPress', $link);
+ $this->assertStringContainsString('utm_campaign=ABPCT_Settings', $link);
+ }
+
+ public function testPresetSfwEnabledExists()
+ {
+ $this->assertArrayHasKey('apbct_hint_sfw__enabled', LinkConstructor::$utm_presets);
+ $preset = LinkConstructor::$utm_presets['apbct_hint_sfw__enabled'];
+ $this->assertSame('apbct_hint_sfw__enabled', $preset['utm_source']);
+ $this->assertSame('WordPress', $preset['utm_medium']);
+ $this->assertSame('ABPCT_Settings', $preset['utm_campaign']);
+ }
+
+ /**
+ * exclusions__form_signs: help/exclusion-by-form-signs (was already using LinkConstructor)
+ * Original: https://cleantalk.org/help/exclusion-by-form-signs?...&utm_source=admin_panel&utm_medium=settings&utm_content=apbct_hint_exclusions__form_signs&utm_campaign=apbct_links
+ */
+ public function testLinkExclusionsFormSigns()
+ {
+ $link = LinkConstructor::buildCleanTalkLink('exclusion_by_form_signs', 'help/exclusion-by-form-signs');
+
+ $this->assertStringStartsWith('https://cleantalk.org/help/exclusion-by-form-signs?', $link);
+ $this->assertStringContainsString('utm_source=admin_panel', $link);
+ $this->assertStringContainsString('utm_medium=settings', $link);
+ $this->assertStringContainsString('utm_content=apbct_hint_exclusions__form_signs', $link);
+ $this->assertStringContainsString('utm_campaign=apbct_links', $link);
+ }
+
+
+
+ // =========================================================================
+ // Structural tests: verify LinkConstructor correctly builds URLs
+ // =========================================================================
+
+ /**
+ * Ensure all hint presets produce URLs with correct domain
+ */
+ public function testAllHintPresetsUseCleantalkDomain()
+ {
+ $hintPresets = array(
+ 'apbct_hint_data__set_cookies',
+ 'apbct_hint_comments__hide_website_field',
+ 'apbct_hint_sfw__anti_crawler',
+ 'apbct_hint_sfw__anti_flood',
+ 'apbct_hint_data__honeypot_field',
+ 'apbct_hint_sfw__enabled',
+ );
+
+ foreach ($hintPresets as $preset) {
+ $link = LinkConstructor::buildCleanTalkLink($preset, 'test-uri');
+ $this->assertStringStartsWith(
+ 'https://cleantalk.org/test-uri?',
+ $link,
+ "Preset '$preset' should produce URL with cleantalk.org domain"
+ );
+ }
+ }
+
+ /**
+ * Ensure data__pixel preset works with blog domain
+ */
+ public function testPixelPresetWithBlogDomain()
+ {
+ $link = LinkConstructor::buildCleanTalkLink(
+ 'apbct_hint_data__pixel',
+ 'test-uri',
+ array(),
+ 'https://blog.cleantalk.org'
+ );
+ $this->assertStringStartsWith('https://blog.cleantalk.org/test-uri?', $link);
+ $this->assertStringNotContainsString('https://cleantalk.org', $link);
+ }
+
+ /**
+ * Verify that all long description presets exist in LinkConstructor
+ */
+ public function testAllLongDescriptionPresetsExist()
+ {
+ $requiredPresets = array(
+ 'apbct_hint_data__set_cookies',
+ 'apbct_hint_comments__hide_website_field',
+ 'apbct_hint_sfw__anti_crawler',
+ 'apbct_hint_sfw__anti_flood',
+ 'apbct_hint_data__pixel',
+ 'apbct_hint_data__honeypot_field',
+ 'apbct_hint_sfw__enabled',
+ 'trp_learn_more_link',
+ 'exclusion_by_form_signs',
+ );
+
+ foreach ($requiredPresets as $preset) {
+ $this->assertArrayHasKey(
+ $preset,
+ LinkConstructor::$utm_presets,
+ "UTM preset '$preset' must exist in LinkConstructor::\$utm_presets"
+ );
+ }
+ }
+
+ /**
+ * Verify that UTM campaign defaults to 'apbct_links' when not specified in preset
+ */
+ public function testDefaultUtmCampaignFallback()
+ {
+ // comments__hide_website_field preset has no utm_campaign
+ $link = LinkConstructor::buildCleanTalkLink(
+ 'apbct_hint_comments__hide_website_field',
+ 'test'
+ );
+ $this->assertStringContainsString('utm_campaign=apbct_links', $link);
+ }
+
+ /**
+ * Verify that UTM campaign from preset overrides default
+ */
+ public function testCustomUtmCampaignInPreset()
+ {
+ // data__set_cookies preset has utm_campaign=ABPCT_Settings
+ $link = LinkConstructor::buildCleanTalkLink(
+ 'apbct_hint_data__set_cookies',
+ 'test'
+ );
+ $this->assertStringContainsString('utm_campaign=ABPCT_Settings', $link);
+ $this->assertStringNotContainsString('utm_campaign=apbct_links', $link);
+ }
+
+ /**
+ * Verify invalid preset throws exception
+ */
+ public function testInvalidPresetThrowsException()
+ {
+ $this->expectException(\Exception::class);
+ LinkConstructor::buildCleanTalkLink('nonexistent_preset', 'test');
+ }
+
+ /**
+ * Full URL exact match tests for regression detection.
+ * These are the canonical URLs that must not change.
+ */
+ public function testExactUrlDataSetCookies()
+ {
+ $link = LinkConstructor::buildCleanTalkLink('apbct_hint_data__set_cookies', 'help/set-cookies-option');
+ $this->assertSame(
+ 'https://cleantalk.org/help/set-cookies-option?utm_id=&utm_term=&utm_source=apbct_hint_data__set_cookies&utm_medium=WordPress&utm_campaign=ABPCT_Settings',
+ $link
+ );
+ }
+
+ public function testExactUrlCommentsHideWebsiteField()
+ {
+ $link = LinkConstructor::buildCleanTalkLink(
+ 'apbct_hint_comments__hide_website_field',
+ 'help/how-to-hide-website-field-in-wordpress-comments'
+ );
+ $this->assertSame(
+ 'https://cleantalk.org/help/how-to-hide-website-field-in-wordpress-comments?utm_id=&utm_term=&utm_source=apbct_hint_comments__hide_website_field&utm_medium=hide_website_field_hint&utm_campaign=apbct_links',
+ $link
+ );
+ }
+
+ public function testExactUrlSfwAntiCrawler()
+ {
+ $link = LinkConstructor::buildCleanTalkLink('apbct_hint_sfw__anti_crawler', 'help/anti-flood-and-anti-crawler');
+ $this->assertSame(
+ 'https://cleantalk.org/help/anti-flood-and-anti-crawler?utm_id=&utm_term=&utm_source=apbct_hint_sfw__anti_crawler&utm_medium=WordPress&utm_campaign=ABPCT_Settings',
+ $link
+ );
+ }
+
+ public function testExactUrlSfwAntiFlood()
+ {
+ $link = LinkConstructor::buildCleanTalkLink('apbct_hint_sfw__anti_flood', 'help/anti-flood-and-anti-crawler');
+ $this->assertSame(
+ 'https://cleantalk.org/help/anti-flood-and-anti-crawler?utm_id=&utm_term=&utm_source=apbct_hint_sfw__anti_flood&utm_medium=WordPress&utm_campaign=ABPCT_Settings',
+ $link
+ );
+ }
+
+ public function testExactUrlDataPixel()
+ {
+ $link = LinkConstructor::buildCleanTalkLink(
+ 'apbct_hint_data__pixel',
+ 'introducing-cleantalk-pixel',
+ array(),
+ 'https://blog.cleantalk.org'
+ );
+ $this->assertSame(
+ 'https://blog.cleantalk.org/introducing-cleantalk-pixel?utm_id=&utm_term=&utm_source=apbct_hint_data__pixel&utm_medium=WordPress&utm_campaign=ABPCT_Settings',
+ $link
+ );
+ }
+
+ public function testExactUrlDataHoneypotField()
+ {
+ $link = LinkConstructor::buildCleanTalkLink('apbct_hint_data__honeypot_field', 'help/honeypot-field');
+ $this->assertSame(
+ 'https://cleantalk.org/help/honeypot-field?utm_id=&utm_term=&utm_source=apbct_hint_data__honeypot_field&utm_medium=WordPress&utm_campaign=ABPCT_Settings',
+ $link
+ );
+ }
+
+ public function testExactUrlSfwEnabled()
+ {
+ $link = LinkConstructor::buildCleanTalkLink('apbct_hint_sfw__enabled', 'help/anti-flood-and-anti-crawler');
+ $this->assertSame(
+ 'https://cleantalk.org/help/anti-flood-and-anti-crawler?utm_id=&utm_term=&utm_source=apbct_hint_sfw__enabled&utm_medium=WordPress&utm_campaign=ABPCT_Settings',
+ $link
+ );
+ }
+
+ public function testExactUrlExclusionByFormSigns()
+ {
+ $link = LinkConstructor::buildCleanTalkLink('exclusion_by_form_signs', 'help/exclusion-by-form-signs');
+ $this->assertSame(
+ 'https://cleantalk.org/help/exclusion-by-form-signs?utm_id=&utm_term=&utm_source=admin_panel&utm_medium=settings&utm_content=apbct_hint_exclusions__form_signs&utm_campaign=apbct_links',
+ $link
+ );
+ }
+
+ public function testExactUrlTrpLearnMoreLink()
+ {
+ $link = LinkConstructor::buildCleanTalkLink('trp_learn_more_link', 'the-real-person');
+ $this->assertSame(
+ 'https://cleantalk.org/the-real-person?utm_id=&utm_term=&utm_source=admin_side&utm_medium=trp_badge&utm_content=trp_badge_link_click&utm_campaign=apbct_links',
+ $link
+ );
+ }
+
+ // =========================================================================
+ // Tests for settings page links (second batch migration)
+ // =========================================================================
+
+ /**
+ * Line 116: Support link (SFW empty warning)
+ */
+ public function testLinkSettingsSupportOpen()
+ {
+ $link = LinkConstructor::buildCleanTalkLink('settings_support_open', 'my/support/open');
+
+ $this->assertStringStartsWith('https://cleantalk.org/my/support/open?', $link);
+ $this->assertStringContainsString('utm_source=admin_panel', $link);
+ $this->assertStringContainsString('utm_medium=settings', $link);
+ $this->assertStringContainsString('utm_content=sfw_support_link', $link);
+ $this->assertStringContainsString('utm_campaign=apbct_links', $link);
+ }
+
+ public function testExactUrlSettingsSupportOpen()
+ {
+ $link = LinkConstructor::buildCleanTalkLink('settings_support_open', 'my/support/open');
+ $this->assertSame(
+ 'https://cleantalk.org/my/support/open?utm_id=&utm_term=&utm_source=admin_panel&utm_medium=settings&utm_content=sfw_support_link&utm_campaign=apbct_links',
+ $link
+ );
+ }
+
+ /**
+ * Line 291: Blog search form protection
+ */
+ public function testLinkBlogSearchFormProtection()
+ {
+ $link = LinkConstructor::buildCleanTalkLink(
+ 'blog_search_form_protection',
+ 'how-to-protect-website-search-from-spambots',
+ array(),
+ 'https://blog.cleantalk.org'
+ );
+
+ $this->assertStringStartsWith('https://blog.cleantalk.org/how-to-protect-website-search-from-spambots?', $link);
+ $this->assertStringContainsString('utm_source=admin_panel', $link);
+ $this->assertStringContainsString('utm_medium=settings', $link);
+ $this->assertStringContainsString('utm_content=search_form_protection', $link);
+ }
+
+ public function testExactUrlBlogSearchFormProtection()
+ {
+ $link = LinkConstructor::buildCleanTalkLink(
+ 'blog_search_form_protection',
+ 'how-to-protect-website-search-from-spambots',
+ array(),
+ 'https://blog.cleantalk.org'
+ );
+ $this->assertSame(
+ 'https://blog.cleantalk.org/how-to-protect-website-search-from-spambots?utm_id=&utm_term=&utm_source=admin_panel&utm_medium=settings&utm_content=search_form_protection&utm_campaign=apbct_links',
+ $link
+ );
+ }
+
+ /**
+ * Line 530: Dashboard link with user_token and cp_mode
+ */
+ public function testLinkSettingsDashboard()
+ {
+ $link = LinkConstructor::buildCleanTalkLink(
+ 'settings_dashboard_link',
+ 'my',
+ array('user_token' => 'test_token_123', 'cp_mode' => 'antispam')
+ );
+
+ $this->assertStringStartsWith('https://cleantalk.org/my?', $link);
+ $this->assertStringContainsString('user_token=test_token_123', $link);
+ $this->assertStringContainsString('cp_mode=antispam', $link);
+ $this->assertStringContainsString('utm_source=wp-backend', $link);
+ $this->assertStringContainsString('utm_medium=admin-bar', $link);
+ $this->assertStringContainsString('utm_content=settings_dashboard_link', $link);
+ }
+
+ /**
+ * Line 668: Email existence alert help link
+ */
+ public function testLinkSettingsEmailExistenceAlert()
+ {
+ $link = LinkConstructor::buildCleanTalkLink('settings_email_existence_alert', 'help/show-email-existence-alert');
+
+ $this->assertStringStartsWith('https://cleantalk.org/help/show-email-existence-alert?', $link);
+ $this->assertStringContainsString('utm_source=admin_panel', $link);
+ $this->assertStringContainsString('utm_medium=settings', $link);
+ $this->assertStringContainsString('utm_content=email_existence_alert', $link);
+ }
+
+ public function testExactUrlSettingsEmailExistenceAlert()
+ {
+ $link = LinkConstructor::buildCleanTalkLink('settings_email_existence_alert', 'help/show-email-existence-alert');
+ $this->assertSame(
+ 'https://cleantalk.org/help/show-email-existence-alert?utm_id=&utm_term=&utm_source=admin_panel&utm_medium=settings&utm_content=email_existence_alert&utm_campaign=apbct_links',
+ $link
+ );
+ }
+
+ /**
+ * Line 1129: Hoster API key profile link with #api_keys anchor
+ */
+ public function testLinkSettingsHosterApiKey()
+ {
+ $link = LinkConstructor::buildCleanTalkLink('settings_hoster_api_key', 'my/profile');
+
+ $this->assertStringStartsWith('https://cleantalk.org/my/profile?', $link);
+ $this->assertStringContainsString('utm_source=admin_panel', $link);
+ $this->assertStringContainsString('utm_medium=settings', $link);
+ $this->assertStringContainsString('utm_content=hoster_api_key', $link);
+
+ // Verify anchor can be appended
+ $linkWithAnchor = $link . '#api_keys';
+ $this->assertStringEndsWith('#api_keys', $linkWithAnchor);
+ }
+
+ /**
+ * Line 1374: Cloud Dashboard button with user_token
+ */
+ public function testLinkSettingsCloudDashboardButton()
+ {
+ $link = LinkConstructor::buildCleanTalkLink(
+ 'settings_cloud_dashboard_button',
+ 'my',
+ array('user_token' => 'abc123', 'cp_mode' => 'antispam')
+ );
+
+ $this->assertStringStartsWith('https://cleantalk.org/my?', $link);
+ $this->assertStringContainsString('user_token=abc123', $link);
+ $this->assertStringContainsString('cp_mode=antispam', $link);
+ $this->assertStringContainsString('utm_source=admin_panel', $link);
+ $this->assertStringContainsString('utm_medium=settings', $link);
+ $this->assertStringContainsString('utm_content=cloud_dashboard_button', $link);
+ }
+
+ /**
+ * Line 1879: Blog email validation status link
+ */
+ public function testLinkBlogEmailValidationStatus()
+ {
+ $link = LinkConstructor::buildCleanTalkLink(
+ 'blog_email_validation_status',
+ 'real-time-email-address-existence-validation',
+ array(),
+ 'https://blog.cleantalk.org'
+ );
+
+ $this->assertStringStartsWith('https://blog.cleantalk.org/real-time-email-address-existence-validation?', $link);
+ $this->assertStringContainsString('utm_source=admin_panel', $link);
+ $this->assertStringContainsString('utm_medium=settings', $link);
+ $this->assertStringContainsString('utm_content=email_validation_status', $link);
+ }
+
+ public function testExactUrlBlogEmailValidationStatus()
+ {
+ $link = LinkConstructor::buildCleanTalkLink(
+ 'blog_email_validation_status',
+ 'real-time-email-address-existence-validation',
+ array(),
+ 'https://blog.cleantalk.org'
+ );
+ $this->assertSame(
+ 'https://blog.cleantalk.org/real-time-email-address-existence-validation?utm_id=&utm_term=&utm_source=admin_panel&utm_medium=settings&utm_content=email_validation_status&utm_campaign=apbct_links',
+ $link
+ );
+ }
+
+ /**
+ * Line 2013: Public offer link
+ */
+ public function testLinkSettingsPublicOffer()
+ {
+ $link = LinkConstructor::buildCleanTalkLink('settings_public_offer', 'publicoffer');
+
+ $this->assertStringStartsWith('https://cleantalk.org/publicoffer?', $link);
+ $this->assertStringContainsString('utm_source=admin_panel', $link);
+ $this->assertStringContainsString('utm_medium=settings', $link);
+ $this->assertStringContainsString('utm_content=public_offer', $link);
+ }
+
+ public function testExactUrlSettingsPublicOffer()
+ {
+ $link = LinkConstructor::buildCleanTalkLink('settings_public_offer', 'publicoffer');
+ $this->assertSame(
+ 'https://cleantalk.org/publicoffer?utm_id=&utm_term=&utm_source=admin_panel&utm_medium=settings&utm_content=public_offer&utm_campaign=apbct_links',
+ $link
+ );
+ }
+
+ /**
+ * Verify all new settings page presets exist
+ */
+ public function testAllSettingsPagePresetsExist()
+ {
+ $requiredPresets = array(
+ 'settings_support_open',
+ 'blog_search_form_protection',
+ 'settings_dashboard_link',
+ 'settings_email_existence_alert',
+ 'settings_hoster_api_key',
+ 'settings_cloud_dashboard_button',
+ 'blog_email_validation_status',
+ 'settings_public_offer',
+ );
+
+ foreach ($requiredPresets as $preset) {
+ $this->assertArrayHasKey(
+ $preset,
+ LinkConstructor::$utm_presets,
+ "UTM preset '$preset' must exist in LinkConstructor::\$utm_presets"
+ );
+ }
+ }
+}
diff --git a/tests/ApbctWP/TestWcFieldSwapHoneypot.php b/tests/ApbctWP/TestWcFieldSwapHoneypot.php
new file mode 100644
index 000000000..53a3ffaa5
--- /dev/null
+++ b/tests/ApbctWP/TestWcFieldSwapHoneypot.php
@@ -0,0 +1,179 @@
+assertSame(10, strlen($secret));
+ $this->assertSame($secret, WcFieldSwapHoneypot::getSecretName('billing_email'));
+ $this->assertFalse(is_numeric($secret[0]));
+ }
+
+ public function testEvaluateFromPostedDataReturnsNullWhenSwapFieldsMissing()
+ {
+ $result = WcFieldSwapHoneypot::evaluateFromPostedData(array());
+
+ $this->assertNull($result['status']);
+ $this->assertNull($result['value']);
+ $this->assertNull($result['source']);
+ }
+
+ public function testEvaluateFromPostedDataDetectsFilledTrap()
+ {
+ $result = WcFieldSwapHoneypot::evaluateFromPostedData(array(
+ 'billing_email' => 'bot@spam.test',
+ ));
+
+ $this->assertSame(0, $result['status']);
+ $this->assertSame('bot@spam.test', $result['value']);
+ $this->assertSame('billing_email', $result['source']);
+ }
+
+ public function testEvaluateFromPostedDataDetectsCleanTrap()
+ {
+ $secret = WcFieldSwapHoneypot::getSecretName('billing_email');
+
+ $result = WcFieldSwapHoneypot::evaluateFromPostedData(array(
+ 'billing_email' => '',
+ $secret => 'user@example.com',
+ ));
+
+ $this->assertSame(1, $result['status']);
+ $this->assertNull($result['value']);
+ $this->assertSame('billing_email', $result['source']);
+ }
+
+ public function testCaptureCheckoutResultDetectsTrapFromRawPost()
+ {
+ global $apbct;
+ $apbct = (object) array(
+ 'settings' => array(
+ 'data__honeypot_field' => 1,
+ ),
+ );
+
+ $secret = WcFieldSwapHoneypot::getSecretName('billing_email');
+ $_POST = array(
+ 'wc-ajax' => 'checkout',
+ 'billing_email' => 'bot@spam.test',
+ $secret => '',
+ );
+
+ $wc_posted_data = array(
+ $secret => '',
+ );
+
+ WcFieldSwapHoneypot::captureCheckoutResult($wc_posted_data);
+ $result = WcFieldSwapHoneypot::getCheckoutResult();
+
+ $this->assertSame(0, $result['status']);
+ $this->assertSame('bot@spam.test', $result['value']);
+ $this->assertSame('billing_email', $result['source']);
+
+ unset($_POST);
+ unset($apbct);
+ }
+
+ public function testCaptureCheckoutResultDoesNotChangePostedDataKeys()
+ {
+ global $apbct;
+ $apbct = (object) array(
+ 'settings' => array(
+ 'data__honeypot_field' => 1,
+ ),
+ );
+
+ $secret = WcFieldSwapHoneypot::getSecretName('billing_email');
+ $_POST = array(
+ 'wc-ajax' => 'checkout',
+ 'billing_email' => '',
+ $secret => 'user@example.com',
+ );
+
+ $posted_data = array(
+ 'billing_email' => '',
+ $secret => 'user@example.com',
+ );
+
+ $captured = WcFieldSwapHoneypot::captureCheckoutResult($posted_data);
+
+ $this->assertSame('user@example.com', $captured[$secret]);
+ $this->assertArrayHasKey($secret, $captured);
+ $this->assertSame(1, WcFieldSwapHoneypot::getCheckoutResult()['status']);
+
+ unset($_POST);
+ unset($apbct);
+ }
+
+ public function testEnrichInputArrayForCleanTalkAddsBillingEmail()
+ {
+ global $apbct;
+ $apbct = (object) array(
+ 'settings' => array(
+ 'data__honeypot_field' => 1,
+ ),
+ );
+
+ $secret = WcFieldSwapHoneypot::getSecretName('billing_email');
+ $_POST = array(
+ 'wc-ajax' => 'checkout',
+ 'billing_email' => '',
+ $secret => 'user@example.com',
+ );
+
+ $enriched = WcFieldSwapHoneypot::enrichInputArrayForCleanTalk(array(
+ 'billing_email' => '',
+ $secret => 'user@example.com',
+ ));
+
+ $this->assertSame('user@example.com', $enriched['billing_email']);
+ $this->assertSame('user@example.com', $enriched[$secret]);
+
+ unset($_POST);
+ unset($apbct);
+ }
+
+ public function testSwapCheckoutFieldsRenamesBillingEmail()
+ {
+ global $apbct;
+ $apbct = (object) array(
+ 'settings' => array(
+ 'data__honeypot_field' => 1,
+ ),
+ );
+
+ $fields = array(
+ 'billing' => array(
+ 'billing_email' => array(
+ 'type' => 'email',
+ 'label' => 'Email',
+ 'required' => true,
+ ),
+ ),
+ );
+
+ $swapped = WcFieldSwapHoneypot::swapCheckoutFields($fields);
+ $secret = WcFieldSwapHoneypot::getSecretName('billing_email');
+
+ $this->assertArrayNotHasKey('billing_email', $swapped['billing']);
+ $this->assertArrayHasKey($secret, $swapped['billing']);
+ $this->assertSame($secret, $swapped['billing'][$secret]['id']);
+
+ unset($apbct);
+ }
+}
diff --git a/tests/Inc/TestCleantalkSettingsLinks.php b/tests/Inc/TestCleantalkSettingsLinks.php
new file mode 100644
index 000000000..9fdf4bdea
--- /dev/null
+++ b/tests/Inc/TestCleantalkSettingsLinks.php
@@ -0,0 +1,216 @@
+data['bot_detector_enabled'] = 1;
+ $apbct->data['wl_brandname'] = 'Anti-Spam by CleanTalk';
+ $apbct->data['wl_mode_enabled'] = false;
+ $apbct->data['moderate'] = 1;
+ $apbct->data['moderate_ip'] = 0;
+ $apbct->data['ip_license'] = 0;
+ $this->apbct = $apbct;
+ }
+
+ protected function tearDown(): void
+ {
+ global $apbct;
+ unset($apbct);
+
+ // Reset Post singleton cache so $_POST changes are visible between tests
+ $instance = Post::getInstance();
+ $instance->variables = array();
+
+ parent::tearDown();
+ }
+
+ // =========================================================================
+ // apbct_settings__set_fields() — covers lines 115, 290, 530, 668
+ // =========================================================================
+
+ /**
+ * Line 290 (blog_search_form_protection) - inside `! $apbct->white_label || is_main_site()`
+ * Line 530 (settings_dashboard_link) - inside `! $apbct->white_label && ! $apbct->data["wl_mode_enabled"]`
+ * Line 668 (settings_email_existence_alert) - unconditional
+ */
+ public function testSetFieldsCoversSearchAndDashboardAndEmailLinks()
+ {
+ $fields = apbct_settings__set_fields();
+
+ // Line 290: forms__search_test description should contain the blog link
+ $this->assertArrayHasKey('forms_protection', $fields);
+ $search_field = $fields['forms_protection']['fields']['forms__search_test'];
+ $this->assertStringContainsString('blog.cleantalk.org', $search_field['description']);
+ $this->assertStringContainsString('utm_content=search_form_protection', $search_field['description']);
+
+ // Line 530: data__general_postdata_test description should contain dashboard link
+ $this->assertArrayHasKey('data_processing', $fields);
+ $postdata_field = $fields['data_processing']['fields']['data__general_postdata_test'];
+ $this->assertStringContainsString('cleantalk.org/my', $postdata_field['description']);
+ $this->assertStringContainsString('utm_content=settings_dashboard_link', $postdata_field['description']);
+
+ // Line 668: data__email_check_exist_post description should contain email alert link
+ $email_field = $fields['data_processing']['fields']['data__email_check_exist_post'];
+ $this->assertStringContainsString('help/show-email-existence-alert', $email_field['description']);
+ $this->assertStringContainsString('utm_content=email_existence_alert', $email_field['description']);
+ }
+
+ /**
+ * Line 115 (settings_support_open) - inside condition:
+ * $apbct->api_key && is_null($apbct->fw_stats['firewall_updating_id'])
+ * && $apbct->settings['sfw__enabled'] && ! $apbct->stats['sfw']['entries']
+ */
+ public function testSetFieldsCoversSupportOpenLink()
+ {
+ global $apbct;
+
+ // Set up conditions to hit line 115
+ $apbct->storage['api_key'] = 'test_key_12345678';
+ $apbct->storage['fw_stats'] = array('firewall_updating_id' => null);
+ $apbct->storage['settings'] = array_merge(
+ $apbct->default_settings,
+ array('sfw__enabled' => 1)
+ );
+ $apbct->storage['stats'] = array('sfw' => array('entries' => 0));
+
+ $fields = apbct_settings__set_fields();
+
+ // The anti_crawler title should contain the support link
+ $anti_crawler = $fields['different']['fields']['sfw__anti_crawler'];
+ $this->assertStringContainsString('my/support/open', $anti_crawler['title']);
+ $this->assertStringContainsString('utm_content=sfw_support_link', $anti_crawler['title']);
+ }
+
+ // =========================================================================
+ // apbct_settings__field__state() — covers line 1873
+ // =========================================================================
+
+ /**
+ * Line 1873 (blog_email_validation_status) - inside `! $apbct->white_label || is_main_site()`
+ */
+ public function testFieldStateCoversEmailValidationLink()
+ {
+ ob_start();
+ apbct_settings__field__state();
+ $output = ob_get_clean();
+
+ $this->assertStringContainsString('blog.cleantalk.org', $output);
+ $this->assertStringContainsString('real-time-email-address-existence-validation', $output);
+ $this->assertStringContainsString('utm_content=email_validation_status', $output);
+ }
+
+ // =========================================================================
+ // apbct_settings__field__apikey() — covers line 2007
+ // =========================================================================
+
+ /**
+ * Line 2007 (settings_public_offer) - unconditional after passing early returns.
+ * Needs: apbct__is_hosting_license() = false, not WPMS sub-site
+ */
+ public function testFieldApikeyCoversPublicOfferLink()
+ {
+ global $apbct;
+ $apbct->data['moderate_ip'] = 0;
+ $apbct->data['ip_license'] = 0;
+
+ ob_start();
+ apbct_settings__field__apikey();
+ $output = ob_get_clean();
+
+ $this->assertStringContainsString('publicoffer', $output);
+ $this->assertStringContainsString('utm_content=public_offer', $output);
+ }
+
+ // =========================================================================
+ // apbct_settings__get_long_descriptions_data() — covers lines 3045-3104
+ // =========================================================================
+
+ /**
+ * Covers all LinkConstructor calls in long descriptions array.
+ * Lines: 3045, 3052, 3057, 3070, 3079, 3086, 3093, 3104
+ *
+ * Calls the extracted helper function directly (no die/wp_die involved).
+ */
+ public function testLongDescriptionCoversAllLinks()
+ {
+ $descriptions = apbct_settings__get_long_descriptions_data();
+
+ $this->assertIsArray($descriptions);
+ $this->assertArrayHasKey('data__set_cookies', $descriptions);
+ $this->assertStringContainsString('help/set-cookies-option', $descriptions['data__set_cookies']['desc']);
+ $this->assertStringContainsString('apbct_hint_data__set_cookies', $descriptions['data__set_cookies']['desc']);
+ }
+
+ /**
+ * Test anti_crawler long description link with anchor.
+ */
+ public function testLongDescriptionAntiCrawlerLink()
+ {
+ $descriptions = apbct_settings__get_long_descriptions_data();
+
+ $this->assertArrayHasKey('sfw__anti_crawler', $descriptions);
+ $this->assertStringContainsString('help/anti-flood-and-anti-crawler', $descriptions['sfw__anti_crawler']['desc']);
+ $this->assertStringContainsString('#anticrawl', $descriptions['sfw__anti_crawler']['desc']);
+ }
+
+ /**
+ * Test sfw__enabled long description (line 3104).
+ */
+ public function testLongDescriptionSfwEnabledLink()
+ {
+ $descriptions = apbct_settings__get_long_descriptions_data();
+
+ $this->assertArrayHasKey('sfw__enabled', $descriptions);
+ $this->assertStringContainsString('help/anti-flood-and-anti-crawler', $descriptions['sfw__enabled']['desc']);
+ $this->assertStringContainsString('apbct_hint_sfw__enabled', $descriptions['sfw__enabled']['desc']);
+ }
+
+ /**
+ * Test data__pixel long description (line 3086) with blog.cleantalk.org domain.
+ */
+ public function testLongDescriptionPixelLink()
+ {
+ $descriptions = apbct_settings__get_long_descriptions_data();
+
+ $this->assertArrayHasKey('data__pixel', $descriptions);
+ $this->assertStringContainsString('blog.cleantalk.org', $descriptions['data__pixel']['desc']);
+ $this->assertStringContainsString('introducing-cleantalk-pixel', $descriptions['data__pixel']['desc']);
+ }
+
+ /**
+ * Test data__honeypot_field long description (line 3093).
+ */
+ public function testLongDescriptionHoneypotLink()
+ {
+ $descriptions = apbct_settings__get_long_descriptions_data();
+
+ $this->assertArrayHasKey('data__honeypot_field', $descriptions);
+ $this->assertStringContainsString('help/honeypot-field', $descriptions['data__honeypot_field']['desc']);
+ $this->assertStringContainsString('apbct_hint_data__honeypot_field', $descriptions['data__honeypot_field']['desc']);
+ }
+}