From ec2a26adeea08846705650a4aa6c90c75671d666 Mon Sep 17 00:00:00 2001 From: AntonV1211 Date: Thu, 15 Oct 2026 16:23:49 +0700 Subject: [PATCH 1/6] New. OfflineProtection. Offline spam protection has been implemented --- js/ct-js-check.js | 30 +++++ lib/CleantalkAntispam.php | 236 +++++++++++++++++++++++++++++++++++--- lib/CleantalkVerdict.php | 5 + 3 files changed, 257 insertions(+), 14 deletions(-) create mode 100644 js/ct-js-check.js diff --git a/js/ct-js-check.js b/js/ct-js-check.js new file mode 100644 index 0000000..a559f43 --- /dev/null +++ b/js/ct-js-check.js @@ -0,0 +1,30 @@ +/** + * CleanTalk offline JavaScript check. + * + * Copies the server generated hash from the data-ct-js-key attribute into a cookie. + * The backend compares the cookie with the same hash when the moderate server is unreachable: + * a matching value proves that JavaScript is executed in the visitor browser. + */ +(function () { + 'use strict'; + + var self = document.currentScript || (function () { + var scripts = document.getElementsByTagName('script'); + return scripts[scripts.length - 1]; + })(); + + if (!self) { + return; + } + + var key = self.getAttribute('data-ct-js-key'); + + // Reject anything but a sha256 hex digest to keep arbitrary data out of the cookie header + if (!key || !/^[a-f0-9]{64}$/.test(key)) { + return; + } + + var secure = location.protocol === 'https:' ? '; secure' : ''; + + document.cookie = 'ct_js_check=' + key + '; path=/; max-age=86400; samesite=lax' + secure; +})(); diff --git a/lib/CleantalkAntispam.php b/lib/CleantalkAntispam.php index 7f7cd82..ffe9be5 100644 --- a/lib/CleantalkAntispam.php +++ b/lib/CleantalkAntispam.php @@ -6,14 +6,20 @@ use CleanTalk\HTTP\CleantalkResponse; use CleanTalk\HTTP\Helper; use CleanTalk\HTTP\Request; +use CleanTalk\HTTP\Response; class CleantalkAntispam { const VERSION = '4.4'; - const MODERATE_URL = 'https://moderate.cleantalk.org/api2.0'; + //const MODERATE_URL = 'https://moderate.cleantalk.org/api2.0'; + //const MODERATE_URL = 'https://moderate_11111.cleantalk.org/api2.0'; + const MODERATE_URL = 'https://192.0.2.1/api2.0'; const BOT_DETECTOR_LIBRARY_URL = 'https://fd.cleantalk.org/ct-bot-detector-wrapper.js'; const EVENT_TOKEN_FIELD_NAME = 'ct_bot_detector_event_token'; const EMAIL_ADDRESS_REGEXP = '/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/'; + const JS_CHECK_COOKIE_NAME = 'ct_js_check'; + const JS_CHECK_SCRIPT_NAME = 'ct-js-check.js'; + const DEFAULT_MODERATE_TIMEOUT = 2; /** * @var string Access key for CleanTalk API @@ -31,6 +37,10 @@ class CleantalkAntispam * @var bool */ private $block_no_js_visitor = false; + /** + * @var string|null Why the verdict has been made locally + */ + private $offline_reason = null; /** * @var string Nickname of the sender */ @@ -127,17 +137,155 @@ private function setEmailAutomatically() */ /** - * Get the frontend HTML code for the CleanTalk bot detector. + * Get the frontend HTML code for the CleanTalk bot detector and the offline JavaScript check. * + * @param string|bool $access_key Access key for CleanTalk API, required for the offline JavaScript check * @param bool $warn_if_js_disabled Flag to include a warning if JavaScript is disabled * @return string HTML code */ - public static function getFrontendHTMLCode($warn_if_js_disabled = false) + public static function getFrontendHTMLCode($access_key = '', $warn_if_js_disabled = false) + { + // BC: the first parameter used to be $warn_if_js_disabled + if (is_bool($access_key)) { + $warn_if_js_disabled = $access_key; + $access_key = ''; + } + + $html = sprintf( + '', + htmlspecialchars(static::BOT_DETECTOR_LIBRARY_URL . '?version=' . self::VERSION, ENT_QUOTES) + ); + + $js_check_url = static::getJsCheckScriptUrl(); + + if (!empty($access_key) && is_string($access_key) && $js_check_url !== null) { + $html .= sprintf( + '', + htmlspecialchars($js_check_url, ENT_QUOTES), + htmlspecialchars(static::getJsCheckHash($access_key), ENT_QUOTES) + ); + } + + if ($warn_if_js_disabled) { + $html .= ''; + } + + return $html; + } + + /** + * Build the hash proving that JavaScript is executed in the visitor browser. + * + * The value must be identical when the page is rendered and when the form is checked, + * so it is derived only from data available on both sides without any storage. + * + * @param string $access_key Access key for CleanTalk API + * @return string sha256 hex digest + */ + private static function getJsCheckHash($access_key) + { + $email = defined('CLEANTALK_ACCOUNT_EMAIL') ? (string)CLEANTALK_ACCOUNT_EMAIL : ''; + $salt = defined('CLEANTALK_SALT') ? (string)CLEANTALK_SALT : ''; + + return hash('sha256', (string)$access_key . $email . $salt); + } + + /** + * Get the public URL of the JavaScript check file shipped with the library. + * + * @return string|null Null if the library directory is not reachable from the web + */ + private static function getJsCheckScriptUrl() + { + if (defined('CLEANTALK_JS_URL')) { + return rtrim((string)CLEANTALK_JS_URL, '/') + . '/' . static::JS_CHECK_SCRIPT_NAME . '?version=' . self::VERSION; + } + + $doc_root = !empty($_SERVER['DOCUMENT_ROOT']) ? realpath($_SERVER['DOCUMENT_ROOT']) : false; + $js_dir = realpath(__DIR__ . '/../js'); + + if ($doc_root === false || $js_dir === false) { + return null; + } + + $doc_root = rtrim(str_replace('\\', '/', $doc_root), '/'); + $js_dir = str_replace('\\', '/', $js_dir); + + if (strpos($js_dir, $doc_root . '/') !== 0) { + return null; + } + + return substr($js_dir, strlen($doc_root)) + . '/' . static::JS_CHECK_SCRIPT_NAME . '?version=' . self::VERSION; + } + + /** + * ------------ Offline mode methods ------------ + */ + + /** + * Check if the moderate server should not be requested at all. + * + * @return bool + */ + private function isForcedOfflineMode() + { + return defined('CLEANTALK_OFFLINE_MODE') && CLEANTALK_OFFLINE_MODE; + } + + /** + * Get the moderate server response timeout in seconds. + * + * @return int + */ + private function getModerateTimeout() + { + if (defined('CLEANTALK_MODERATE_TIMEOUT') && (int)CLEANTALK_MODERATE_TIMEOUT > 0) { + return (int)CLEANTALK_MODERATE_TIMEOUT; + } + + return static::DEFAULT_MODERATE_TIMEOUT; + } + + /** + * Check if the visitor returned the hash issued by getFrontendHTMLCode(). + * + * @return bool + */ + private function isJsCheckPassed() + { + if (empty($_COOKIE[static::JS_CHECK_COOKIE_NAME])) { + return false; + } + + $received = $_COOKIE[static::JS_CHECK_COOKIE_NAME]; + + if (!is_string($received)) { + return false; + } + + return hash_equals(static::getJsCheckHash($this->access_key), $received); + } + + /** + * Make the verdict locally, without the moderate server. + * + * @param string $reason Why the local verdict is used + */ + private function applyOfflineVerdict($reason) { - $warn = $warn_if_js_disabled ? '' : ''; - $url = static::BOT_DETECTOR_LIBRARY_URL . '?version=' . self::VERSION; - $html = '%s'; - return sprintf($html, $url, $warn); + $this->offline_reason = $reason; + $this->verdict->offline = true; + + if ($this->isJsCheckPassed()) { + $this->verdict->allow = 1; + $this->verdict->comment = 'Offline mode: the JavaScript check has been passed.'; + return; + } + + $this->verdict->allow = 0; + $this->verdict->comment = 'Please, enable JavaScript to process the form.'; } /** @@ -153,12 +301,32 @@ private function getCleanTalkResponse() { $http = new Request(); $this->cleantalk_request_data = $this->prepareCleanTalkRequestData(); - $response_raw = $http->setUrl(static::MODERATE_URL) - ->setData($this->cleantalk_request_data) - ->request(); + $timeout = $this->getModerateTimeout(); + $http->setUrl(static::MODERATE_URL) + ->setData($this->cleantalk_request_data) + ->setOptions(array( + CURLOPT_TIMEOUT => $timeout, + CURLOPT_CONNECTTIMEOUT => $timeout, + )); + + $response_raw = $http->request(); + $http_code = $http->response instanceof Response ? (int)$http->response->getResponseCode() : 0; + + // Anything but HTTP 200 with a JSON object is treated as the server being unreachable + $decoded = $http_code === 200 && is_string($response_raw) ? @json_decode($response_raw) : null; + $response = new CleantalkResponse(is_object($decoded) ? $decoded : null, null); + + if (!is_object($decoded)) { + if (is_array($response_raw) && !empty($response_raw['error'])) { + $response->error = (string)$response_raw['error']; + } elseif ($http_code !== 200) { + $response->error = 'HTTP code ' . $http_code; + } else { + $response->error = 'Unexpected response from the moderate server'; + } + } - /** @psalm-suppress InvalidArgument */ - return new CleantalkResponse(@json_decode($response_raw), null); + return $response; } /** @@ -241,7 +409,7 @@ private function prepareCleanTalkRequestData() 'sender_ip' => $this->ip, 'js_on' => !empty($this->event_token) ? 1 : 0, 'event_token' => $this->event_token, - 'agent' => 'php-cleantalk-check', + 'agent' => 'php-cleantalk-check-' . static::VERSION, 'all_headers' => $this->getAllHeaders(), 'sender_info' => @json_encode( array( @@ -493,11 +661,17 @@ public function handle() $this->method_name = empty($this->message) ? 'check_newuser' : 'check_message'; } + if ($this->isForcedOfflineMode()) { + $this->cleantalk_request_data = $this->prepareCleanTalkRequestData(); + $this->applyOfflineVerdict('The CLEANTALK_OFFLINE_MODE constant is enabled, the moderate server has not been requested.'); + return $this->beforeReturnVerdict(); + } + $this->cleantalk_response = $this->getCleanTalkResponse(); if ($this->cleantalk_response->error) { /** @psalm-suppress InvalidOperand */ - $this->verdict->error = 'CleanTalk moderate server error: ' . $this->cleantalk_response->error; + $this->applyOfflineVerdict('The moderate server is unreachable: ' . $this->cleantalk_response->error); return $this->beforeReturnVerdict(); } @@ -520,6 +694,8 @@ public function handle() */ private function setImprovementSuggestions() { + $this->setOfflineImprovementSuggestions(); + if ( $this->cleantalk_response instanceof CleantalkResponse ) { if (empty($this->cleantalk_response->comment)) { $this->setImprovementSuggestion( @@ -642,6 +818,38 @@ private function setImprovementSuggestion($level, $message, $stack = null) $this->improvement_suggestions[$level][] = array('stack' => $stack, 'message' => $message); } + /** + * Set suggestions related to the offline protection. + */ + private function setOfflineImprovementSuggestions() + { + if (static::getJsCheckScriptUrl() === null) { + $this->setImprovementSuggestion( + 'critical', + 'The ' . static::JS_CHECK_SCRIPT_NAME . ' file is not reachable from the web, so every visitor will be blocked while the moderate server is down. Please, define the CLEANTALK_JS_URL constant with the public URL of the library js directory.', + 'getJsCheckScriptUrl()' + ); + } + + if ($this->offline_reason === null) { + return; + } + + $this->setImprovementSuggestion( + 'critical', + 'The verdict has been made locally. ' . $this->offline_reason, + 'applyOfflineVerdict()' + ); + + if (!$this->isJsCheckPassed()) { + $this->setImprovementSuggestion( + 'critical', + 'The ' . static::JS_CHECK_COOKIE_NAME . ' cookie is missing or does not match, so the request has been BLOCKED. Make sure getFrontendHTMLCode() is called with the same access key on the page with the form.', + 'isJsCheckPassed()' + ); + } + } + /** * Get the improvement suggestions. * diff --git a/lib/CleantalkVerdict.php b/lib/CleantalkVerdict.php index 53195d0..81ff05b 100644 --- a/lib/CleantalkVerdict.php +++ b/lib/CleantalkVerdict.php @@ -9,6 +9,11 @@ class CleantalkVerdict public $error = ''; public $request_link = null; + /** + * @var bool True if the verdict is made locally because the moderate server was unreachable + */ + public $offline = false; + public function getJSON() { return json_encode($this); From ab6ad90c181d142423b148697067afb90179717d Mon Sep 17 00:00:00 2001 From: AntonV1211 Date: Thu, 15 Oct 2026 17:34:18 +0700 Subject: [PATCH 2/6] Dev. Code. Editing a variable for debugging --- lib/CleantalkAntispam.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/CleantalkAntispam.php b/lib/CleantalkAntispam.php index f798606..35d271e 100644 --- a/lib/CleantalkAntispam.php +++ b/lib/CleantalkAntispam.php @@ -11,9 +11,7 @@ class CleantalkAntispam { const VERSION = '4.4'; - //const MODERATE_URL = 'https://moderate.cleantalk.org/api2.0'; - //const MODERATE_URL = 'https://moderate_11111.cleantalk.org/api2.0'; - const MODERATE_URL = 'https://192.0.2.1/api2.0'; + const MODERATE_URL = 'https://moderate.cleantalk.org/api2.0'; const BOT_DETECTOR_LIBRARY_URL = 'https://fd.cleantalk.org/ct-bot-detector-wrapper.js'; const EVENT_TOKEN_FIELD_NAME = 'ct_bot_detector_event_token'; const EMAIL_ADDRESS_REGEXP = '/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/'; From 33935baa1a89d7c03fd05ceeaede248a8f9bf955 Mon Sep 17 00:00:00 2001 From: AntonV1211 Date: Thu, 15 Oct 2026 18:03:56 +0700 Subject: [PATCH 3/6] New. Readme. Reissuing the README, taking into account offline protection, edits in the integration example --- README.md | 153 +++++++-------- .../form_with_handler/form_with_handler.php | 34 ++-- readme_additional.md | 184 +++++++++--------- 3 files changed, 177 insertions(+), 194 deletions(-) diff --git a/README.md b/README.md index e0b3288..93c2eee 100644 --- a/README.md +++ b/README.md @@ -1,134 +1,115 @@ # CleanTalk php-antispam: Lightweight Spam Protection for PHP Apps ============ - ![example workflow](https://github.com/CleanTalk/php-antispam/actions/workflows/tests.yml/badge.svg) [![Latest Stable Version](https://poser.pugx.org/cleantalk/php-antispam/v)](https://packagist.org/packages/cleantalk/php-antispam) -## Invisible spam protection: no CAPTCHA, puzzles, or math tests - -_A PHP client for the CleanTalk anti-spam API. Spam protection and a CAPTCHA-free alternative to reCAPTCHA, powered by background scoring that doesn’t interrupt website visitors._ - -If you find this project useful, please consider starring it on GitHub ⭐. It helps us improve and maintain the project. +## The Invisible protection from spam, no captcha, no recaptcha, no puzzles, no math captcha. +_API for antispam service cleantalk.org_ #### Requirements +* PHP 5.6 and above +* CURL support -* PHP 5.6 or later -* cURL support - -### How does it stop spam? +### How we stop spam? +PHP Anti-Spam library providing invisible spam protection for your websites, registration forms, and comment sections. CleanTalk API offers an effective CAPTCHA alternative that silently blocks spam without interrupting your users' experience. -This PHP library provides invisible spam protection for websites, registration forms, and comment sections. The CleanTalk API is a CAPTCHA alternative that detects spam without interrupting your users. +When users submit forms on your website form, the form data is securely sent to CleanTalk’s cloud servers. CleanTalk analyzes submissions using advanced heuristics. CleanTalk then returns a real-time verdict— legitimate requests or spam. -When a user submits a form, the library securely sends the form data to CleanTalk's cloud servers for analysis. CleanTalk then returns a real-time verdict identifying the submission as legitimate or spam. Your application decides how to handle that verdict. +You are free to do anything with spam, or just allow as to block spam (we will interrupt desirable request). ## CleanTalk vs CAPTCHA -| Feature | CleanTalk Anti-Spam | Traditional CAPTCHA | -|---------------------|-----------------------------------|------------------------------------------| -| User Interaction | 100% invisible to users | Requires solving puzzles or clicks | -| Form Compatibility | Works with any PHP form | Often requires additional scripts | -| Speed | Instant cloud check | Slower due to user interaction | -| Accessibility | No visual tests | Can be difficult for screen-reader users | +| Feature | CleanTalk Anti-Spam | Traditional CAPTCHA | +|---------------------|-----------------------------------|--------------------------------------| +| User Interaction | 100% invisible to users | Requires solving puzzles or clicks | +| Form Compatibility | Works with any PHP form | Often requires additional scripts | +| Speed | Instant cloud check | Slower due to user interaction | +| Accessibility | Fully accessible, no visual tests | Often inaccessible to screen readers | -> CleanTalk is a **PHP spam filter** and **CAPTCHA-free alternative** that protects your forms without adding friction for users. +> CleanTalk is a **PHP spam filter** and a **captcha-free alternative** that boosts UX and protects your forms with zero friction. -## Getting started +## Interesting? Let's make some settings (it will take few minutes) -Setup takes only a few minutes. - -### Step 1: install the SDK - -Install the SDK with Composer: +## 1. Installation ```bash composer require cleantalk/php-antispam ``` -Alternatively, download the ZIP archive and extract it into your project directory. - -### Step 2: configure your API key +Without Composer: extract the library archive and include `require_once '/path/to/php-antispam/cleantalk-antispam.php';`. -First, copy your API key from your [CleanTalk dashboard](https://cleantalk.org/my/). +## 2. Constants -Make it available to PHP as an environment variable: +Define them in a shared settings file that is loaded **both on the form page and in the form handler**: the JavaScript check hash must match in both places. -| Name | Value | -|---|---| -| `CLEANTALK_API_KEY` | Your CleanTalk API key | - -On managed hosting, add it in the **Environment variables** or **Secrets** section of your hosting control panel, then restart the application if required. - -If your application already loads a `.env` file, add: - -```dotenv -CLEANTALK_API_KEY=your-api-key +```php +define('CLEANTALK_ACCOUNT_EMAIL', 'you@example.com'); // required: CleanTalk account email +define('CLEANTALK_SALT', 'f3c9a1e07b2d4c8e9a6b5d0f1e2c3a4b'); // required: secret string, at least 32 characters +// define('CLEANTALK_JS_URL', '/cleantalk'); // if the library is outside the web root (see step 4) +// define('CLEANTALK_MODERATE_TIMEOUT', 2); // cloud timeout, seconds (default 2 sec) +// define('CLEANTALK_OFFLINE_MODE', true); // testing only: never contact the cloud ``` -PHP does not load `.env` files by itself; this option works only when your application or framework includes a dotenv loader. Do not put the API key in your PHP files or commit it to source control. - -### Step 3: add the CleanTalk handler to your form handler +## 3. Form page -Add the handler to your PHP code: +Print the library scripts before the form or in ``. Use the same access key as in the form handler. -```php linenums="1" -$api_key = getenv('CLEANTALK_API_KEY'); -if (empty($api_key)) { - throw new RuntimeException('CLEANTALK_API_KEY is not configured'); -} - -$email = $_POST['email']; // Get this value from your form. -$cleantalk_antispam = (new CleantalkAntispam($api_key)) - ->setEmail($email); -// Set additional parameters here. -$api_result = $cleantalk_antispam->handle(); +```php + +
+ + + + +
``` -See [additional configuration options](readme_additional.md) to select the form type and provide the sender name, message, IP address, and Bot Detector settings. - -### Step 4: add the JavaScript library to your HTML template +## 4. Making the JS file reachable -The library collects frontend data used for bot detection. +The browser must load `js/ct-js-check.js` from the library directory. If the directory is inside the web root, nothing needs to be done. If it is outside, choose one option. -```html - -``` - -Enable the event token in your PHP handler: +**Option A (recommended): serve the file with your own handler** at `/cleantalk/ct-js-check.js`. This way the file always matches the installed library version. -```php linenums="3" -... -// Set additional parameters here. -$cleantalk_antispam->setEventTokenEnabled(1); -... +```php +header('Content-Type: application/javascript'); +readfile('/path/to/php-antispam/js/ct-js-check.js'); ``` -### Step 5: handle the API verdict - -For example, stop processing when CleanTalk identifies a submission as spam: - ```php -if ($api_result && $api_result->allow === 0) { - die('Blocked. Spam protection OK. Reason: ' . $api_result->comment); - // Or add your own actions, logs, or messages. -} +define('CLEANTALK_JS_URL', '/cleantalk'); ``` -### Step 6 (optional): troubleshoot the integration +**Option B: copy the file** into a public directory, for example `/assets/cleantalk/`, and set `define('CLEANTALK_JS_URL', '/assets/cleantalk');`. Copy the file again after every library update. -To identify possible configuration problems, log the improvement suggestions after calling `handle()`: +## 5. Form handler + +Check the data **before** saving it or sending an email: ```php -// Troubleshooting: log improvement suggestions. -error_log($cleantalk_antispam->whatsWrong(true)); +use CleanTalk\CleantalkAntispam; + +$cleantalk = new CleantalkAntispam($apikey, $_POST['email']); +$result = $cleantalk + ->setEventTokenEnabled(1) // required: Bot Detector is included via getFrontendHTMLCode() + ->setNickName($_POST['name']) // user name, if available + ->setMessage($_POST['message']) // message text, if available + ->useContactFormCheck() // for registration forms: ->useRegistrationCheck() + ->handle(); + +if (!$result->allow) { + // Blocked: do not save the data, show $result->comment +} ``` -The diagnostic output can contain personal data. Use this logging only while troubleshooting and store it in appropriately protected logs. - -See the [complete form-handler example](https://github.com/CleanTalk/php-antispam/blob/dev/examples/form_with_handler/form_with_handler.php) for context. +`$result->offline` is `true` when the decision was made without the cloud. -### Step 7 (optional): get help +## 6. Testing -If you have questions, open a GitHub issue or contact us through our ticket system. +1. Open the form page. In DevTools, make sure `ct-js-check.js` loads with status 200 and the `ct_js_check` cookie is set. +2. Temporarily add `define('CLEANTALK_OFFLINE_MODE', true);`. + - With JavaScript enabled, the form must be **submitted**. + - With JavaScript disabled and the cookie deleted, the form must be **blocked**. +3. Remove `CLEANTALK_OFFLINE_MODE`. ## Examples diff --git a/examples/form_with_handler/form_with_handler.php b/examples/form_with_handler/form_with_handler.php index ae6b5b7..73ee4ff 100644 --- a/examples/form_with_handler/form_with_handler.php +++ b/examples/form_with_handler/form_with_handler.php @@ -1,8 +1,19 @@ @@ -61,8 +72,8 @@ } - - + + @@ -87,26 +98,17 @@ $messageType = 'error'; } - if ($messageType !== 'error' && !filter_var($email, FILTER_VALIDATE_EMAIL)) { - $statusMessage = 'Please enter a valid email address.'; - $messageType = 'error'; - } - // HANDLE CLEANTALK ANTISPAM if ($messageType !== 'error') { - $api_key = getenv('CLEANTALK_API_KEY'); - if (empty($api_key)) { - throw new RuntimeException('CLEANTALK_API_KEY is not configured'); - } - $cleantalk_antispam = (new CleantalkAntispam($api_key)) ->useContactFormCheck() + ->setEventTokenEnabled(1) // required: Bot Detector is included via getFrontendHTMLCode() ->setEmail($email) ->setNickName($name) - ->setMessage($message) - ->setEventTokenEnabled(1); + ->setMessage($message); $api_result = $cleantalk_antispam->handle(); - if ($api_result->allow === 0) { + // $api_result->offline is true when the cloud was unavailable and the JavaScript check decided + if (!$api_result->allow) { $statusMessage = 'Spam detected - ' . $api_result->comment; $messageType = 'error'; } diff --git a/readme_additional.md b/readme_additional.md index 99c39ef..aae3911 100644 --- a/readme_additional.md +++ b/readme_additional.md @@ -2,174 +2,174 @@ Configure the request after creating `CleantalkAntispam` and before calling `handle()`. The configuration methods return the same object, so they can be chained. -Pass actual form values to these methods, not HTML field names. The current constructor uses the access key and the optional second argument as an email address value. The legacy `$user_name_field`, `$message_field`, and `$type_form` constructor arguments are not used by the current implementation. Use the explicit setters below because they make every value sent to CleanTalk clear. +Pass the **values** of form fields to these methods, not their HTML field names. The constructor accepts the access key and, optionally, the sender email. The `$user_name_field`, `$message_field`, and `$type_form` constructor arguments are deprecated and not used: call the methods described below instead. ## Contact form example -Assume that `$email`, `$name`, `$subject`, and `$message` contain values that your application has already read and validated. +Assume that `$email`, `$name`, `$subject`, and `$message` have already been read from the form and validated by your application. ```php -$cleantalk_antispam = (new CleantalkAntispam($api_key)) +$api_result = (new CleantalkAntispam($api_key)) ->useContactFormCheck() + ->setEventTokenEnabled(1) ->setEmail($email) ->setNickName($name) - ->setMessage($subject . ': ' . $message); - -$api_result = $cleantalk_antispam->handle(); + ->setMessage($subject . ': ' . $message) + ->handle(); ``` -`useContactFormCheck()` uses the CleanTalk [`check_message`](https://cleantalk.org/help/api-check-message) API method. CleanTalk uses this method for contact forms and other user-generated content, including comments and reviews. The library does not send a separate contact-form subtype in the API request. +`useContactFormCheck()` uses the [`check_message`](https://cleantalk.org/help/api-check-message) API method. It is intended for contact forms, comments, reviews, and other user-generated content. ## Registration form example -Use the registration check for sign-up, subscription, or similar forms that do not contain a user-written message. See the CleanTalk [`check_newuser`](https://cleantalk.org/help/api-check-newuser) API documentation for the underlying request method. +For registration, subscription, and similar forms without a message, use the [`check_newuser`](https://cleantalk.org/help/api-check-newuser) API method: ```php -$cleantalk_antispam = (new CleantalkAntispam($api_key)) +$api_result = (new CleantalkAntispam($api_key)) ->useRegistrationCheck() + ->setEventTokenEnabled(1) ->setEmail($email) - ->setNickName($name); - -$api_result = $cleantalk_antispam->handle(); + ->setNickName($name) + ->handle(); ``` ## Sender and form data -### `setEmail($email)` +| Method | API parameter | Description | +|---|---|---| +| `setEmail($email)` | `sender_email` | Sender email address. If not set, the library looks for an email address in `$_POST`. Passing the validated value explicitly is more reliable. | +| `setNickName($name)` | `sender_nickname` | Sender name or nickname. | +| `setMessage($text)` | `message` | Text to check: message body, subject, or other text fields. | +| `setIP($ip)` | `sender_ip` | Overrides the automatically detected IP address. Use it only when your application already knows the real visitor IP (for example, behind a trusted proxy). An invalid IP is ignored and a troubleshooting suggestion is added. | -Sets the sender email address. It is sent to the CleanTalk API as `sender_email`. +## Check type -```php -$cleantalk_antispam->setEmail($email); -``` +- `useContactFormCheck()` — the `check_message` method: forms with user-written text. +- `useRegistrationCheck()` — the `check_newuser` method: forms without such text. -If no email is provided explicitly, the library attempts to find an email address in `$_POST`. Explicitly passing the validated email value is more reliable. +If no type is selected, `handle()` uses `check_message` when the message is not empty and `check_newuser` otherwise. Selecting the type explicitly is recommended. -### `setNickName($nickname)` +## Scripts on the form page -Sets the sender name or nickname. It is sent as `sender_nickname` and allows CleanTalk to check the name for spam patterns. +Print the library HTML code on the page with the form, passing **the same access key** as in the form handler: ```php -$cleantalk_antispam->setNickName($name); +echo CleantalkAntispam::getFrontendHTMLCode($api_key); ``` -### `setMessage($message)` - -Sets the user-generated content to check. It is sent as `message` and can contain the message body, subject, or other relevant text fields. +The method outputs: -```php -$cleantalk_antispam->setMessage($subject . ': ' . $message); -``` +1. **Bot Detector** (`fd.cleantalk.org/ct-bot-detector-wrapper.js`), which adds an event token to the form data. +2. **JavaScript check script** (`ct-js-check.js`), which stores the hash for [offline protection](#offline-protection) in the `ct_js_check` cookie. -### `setIP($ip)` +To show a warning to visitors without JavaScript, pass `true` as the second argument: `getFrontendHTMLCode($api_key, true)`. -Overrides the automatically detected sender IP address. Use it only when the application has already determined the original visitor IP, for example behind a trusted proxy. +> The legacy call `getFrontendHTMLCode(true)` without a key still works, but outputs only Bot Detector. Without the JavaScript check script, offline protection will block every visitor. -```php -$cleantalk_antispam->setIP($visitor_ip); -``` +## Bot Detector and event token -If the supplied value is not a valid IP address, the library keeps the IP detected from the current request and adds a troubleshooting suggestion. +### `setEventTokenEnabled(1)` -## Check type +Required when `getFrontendHTMLCode()` is printed on the page. It tells the cloud that Bot Detector is installed. Do not call it for forms without Bot Detector. -### `useContactFormCheck()` +### `setEventToken($event_token)` -Selects the `check_message` API method. Use it for contact forms, comments, reviews, support requests, and other forms containing user-generated text. +The library normally reads the token from `$_POST['ct_bot_detector_event_token']`. If the form data arrives another way (for example, as JSON), pass the token explicitly: ```php -$cleantalk_antispam->useContactFormCheck(); +$cleantalk_antispam + ->setEventToken($event_token) + ->setEventTokenEnabled(1); ``` -### `useRegistrationCheck()` +### `setDoBlockNoJSVisitor()` -Selects the `check_newuser` API method. Use it for registrations, subscriptions, and forms where there is no user-written message to inspect. +By default, a missing token alone does not block the request. With this method, `handle()` immediately returns a blocking verdict when the token is missing or invalid, without sending a request to the cloud. -```php -$cleantalk_antispam->useRegistrationCheck(); -``` +## Offline protection -If neither method is selected, `handle()` chooses `check_message` when a non-empty message has been set and `check_newuser` otherwise. Selecting the intended check explicitly is recommended. +If the CleanTalk cloud is unavailable, the library makes the decision itself based on a JavaScript check. Offline protection is always enabled. -## Bot Detector and event token +**When it is used.** The cloud did not respond within `CLEANTALK_MODERATE_TIMEOUT` seconds, a connection error occurred, the response code was not HTTP 200, or the response was not valid JSON. -Add the Bot Detector script to the HTML page containing the form: +**How the decision is made.** If the `ct_js_check` cookie matches the expected hash, the request is allowed. If the cookie is missing or does not match, the request is blocked. In both cases the verdict has `offline = true`. -```html - -``` +> **Behavior change.** Previously, all requests were allowed when the cloud was unavailable. Now they are blocked without a valid cookie, so the JavaScript check script must be set up. -### `setEventTokenEnabled(1)` +### Constants -When the Bot Detector script is installed for the form, enable event-token processing in the PHP request: +Define them with `define()` before using the library, in code that runs both when the form page is rendered and in the form handler: the hash must match in both places. -```php -$cleantalk_antispam->setEventTokenEnabled(1); -``` +| Constant | Required | Description | Default | +|---|---|---|---| +| `CLEANTALK_ACCOUNT_EMAIL` | yes | CleanTalk account email, used in the hash. | `''` | +| `CLEANTALK_SALT` | yes | Secret string of at least 32 characters. Without it, the hash depends on the access key only. | `''` | +| `CLEANTALK_JS_URL` | if the library is outside the web root | Public URL of the directory containing `ct-js-check.js`. | auto-detected | +| `CLEANTALK_MODERATE_TIMEOUT` | no | Seconds to wait for the cloud. | `2` | +| `CLEANTALK_OFFLINE_MODE` | no | `true` — never contact the cloud. For testing and emergencies only. | `false` | -Do not set this flag for forms that do not use Bot Detector. +### Making `ct-js-check.js` reachable -### `setEventToken($event_token)` +If the library directory is inside the web root, the URL is detected automatically. If it is outside (for example, `vendor/` is above the public directory), choose one option: -The library normally reads the generated token from `$_POST['ct_bot_detector_event_token']`. If the form data is received another way, such as a decoded JSON request, pass the token explicitly: +- **serve the file with your own handler (recommended):** -```php -$cleantalk_antispam - ->setEventToken($event_token) - ->setEventTokenEnabled(1); -``` + ```php + // handler at /cleantalk/ct-js-check.js + header('Content-Type: application/javascript'); + readfile('/path/to/php-antispam/js/ct-js-check.js'); + ``` -### `setDoBlockNoJSVisitor()` + and set `define('CLEANTALK_JS_URL', '/cleantalk');`; -By default, the library does not locally block a visitor only because a valid event token is missing. Call this method if your application deliberately requires JavaScript and should reject requests without a valid token. +- **copy** `js/ct-js-check.js` into a public directory and set its URL in `CLEANTALK_JS_URL`. Copy the file again after every library update. -```php -$cleantalk_antispam->setDoBlockNoJSVisitor(); -``` +The library appends the file name and version to the URL itself: `/cleantalk/ct-js-check.js?version=4.4`. -With this option enabled, `handle()` returns a blocked verdict without sending an API request when the event token is absent or invalid. +### Testing -## Automatically populated request data +1. Temporarily enable `define('CLEANTALK_OFFLINE_MODE', true);`. +2. Submit the form with JavaScript enabled — the request must pass. +3. Disable JavaScript, delete the `ct_js_check` cookie, reload the page, and submit the form — the request must be blocked. +4. Remove `CLEANTALK_OFFLINE_MODE`. -The library also prepares these values automatically: +## Verdict -| API parameter | Source | +| Field | Description | |---|---| -| `sender_ip` | Detected from the current HTTP request unless overridden with `setIP()` | -| `event_token` | Read from `$_POST['ct_bot_detector_event_token']` unless set with `setEventToken()` | -| `js_on` | Set to `1` when an event token is available, otherwise `0` | -| `agent` | Identifies the php-antispam library | -| `all_headers` | Built from the current HTTP headers after sensitive headers are removed | -| `sender_info` | Includes the current referrer when available | -| `submit_time` | Not sent by the current library; CleanTalk does not require it when Bot Detector and `event_token_enabled` are used | - -## Frontend HTML helper +| `allow` | `1` — allow, `0` — block. | +| `comment` | Reason for the decision. | +| `offline` | `true` if the decision was made locally, without the cloud. | +| `request_link` | Link to the request in the CleanTalk dashboard (online mode only). | +| `error` | Error that prevented the check (for example, an empty access key). | -`CleantalkAntispam::getFrontendHTMLCode()` returns the Bot Detector script tag (currently without a `defer` attribute). Pass `true` to also include a warning for visitors who have disabled JavaScript. +## Automatically populated request data -```php -echo CleantalkAntispam::getFrontendHTMLCode(true); -``` +| API parameter | Source | +|---|---| +| `sender_ip` | IP of the current HTTP request, unless set with `setIP()` | +| `event_token` | `$_POST['ct_bot_detector_event_token']`, unless set with `setEventToken()` | +| `js_on` | `1` when an event token is available, otherwise `0` | +| `agent` | Identifies the php-antispam library | +| `all_headers` | HTTP headers of the current request with sensitive headers removed | +| `sender_info` | Referrer, when available | +| `submit_time` | Not sent; not required when Bot Detector and `event_token_enabled` are used | ## Custom form data containers -### `setCustomFormDataContainer($data_container)` - -The current implementation only replaces the internal form data container. It does not map custom keys to email, nickname, or message, and it does not refresh the email or event token that were collected during construction. Do not use this method for field mapping; pass the required values explicitly with `setEmail()`, `setNickName()`, `setMessage()`, and `setEventToken()`. +`setCustomFormDataContainer($data_container)` only replaces the internal form data container. It does not map keys to email, name, or message, and does not refresh the email or token already collected in the constructor. To pass data, use `setEmail()`, `setNickName()`, `setMessage()`, and `setEventToken()`. ## Troubleshooting -Call `whatsWrong()` after `handle()` to inspect improvement suggestions, the prepared request data, and the verdict: +Call `whatsWrong()` after `handle()` to see configuration suggestions, the prepared request data, and the verdict: ```php $api_result = $cleantalk_antispam->handle(); error_log($cleantalk_antispam->whatsWrong(true)); ``` -After `handle()` has prepared a request, `getCleanTalkRequestData()` returns its decoded data for custom diagnostics: +Offline protection suggestions appear when `ct-js-check.js` is not reachable from the browser, when the decision was made without the cloud, or when the `ct_js_check` cookie did not arrive. -```php -$request_data = $cleantalk_antispam->getCleanTalkRequestData(); -``` +`getCleanTalkRequestData()` returns the decoded prepared request data for custom diagnostics. -The diagnostic output can contain email addresses, messages, IP addresses, and other personal data. Store it only in appropriately protected logs and remove temporary diagnostic logging when troubleshooting is complete. +> The diagnostic output contains email addresses, messages, IP addresses, and other personal data. Store it only in protected logs and remove temporary logging after troubleshooting. From e02bb3738644b541eb7ea9f3eae6c79c79b94ce2 Mon Sep 17 00:00:00 2001 From: AntonV1211 Date: Thu, 15 Oct 2026 18:11:26 +0700 Subject: [PATCH 4/6] Dev. Code. Fix psalm error --- lib/CleantalkAntispam.php | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/lib/CleantalkAntispam.php b/lib/CleantalkAntispam.php index 35d271e..3b104ff 100644 --- a/lib/CleantalkAntispam.php +++ b/lib/CleantalkAntispam.php @@ -253,13 +253,12 @@ private function getModerateTimeout() */ private function isJsCheckPassed() { - if (empty($_COOKIE[static::JS_CHECK_COOKIE_NAME])) { - return false; - } - - $received = $_COOKIE[static::JS_CHECK_COOKIE_NAME]; + $received = + isset($_COOKIE[static::JS_CHECK_COOKIE_NAME]) ? + $_COOKIE[static::JS_CHECK_COOKIE_NAME] : + null; - if (!is_string($received)) { + if (!is_string($received) || $received === '') { return false; } From 5e44babcc554a4e78c96c141c12a8a5406583473 Mon Sep 17 00:00:00 2001 From: AntonV1211 Date: Thu, 15 Oct 2026 18:13:11 +0700 Subject: [PATCH 5/6] Dev. Code. Fix psalm error --- lib/CleantalkAntispam.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/CleantalkAntispam.php b/lib/CleantalkAntispam.php index 3b104ff..e35d68d 100644 --- a/lib/CleantalkAntispam.php +++ b/lib/CleantalkAntispam.php @@ -253,7 +253,7 @@ private function getModerateTimeout() */ private function isJsCheckPassed() { - $received = + $received = isset($_COOKIE[static::JS_CHECK_COOKIE_NAME]) ? $_COOKIE[static::JS_CHECK_COOKIE_NAME] : null; From 61ca683aeb2a2f8d8e07fad555db61933a38f9b7 Mon Sep 17 00:00:00 2001 From: AntonV1211 Date: Fri, 16 Oct 2026 14:39:44 +0700 Subject: [PATCH 6/6] Dev. Code. Backward compatibility by constants for offline protection --- README.md | 4 ++-- lib/CleantalkAntispam.php | 47 +++++++++++++++++++++++++++++++++------ readme_additional.md | 18 +++++++-------- 3 files changed, 51 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 93c2eee..b8d73d4 100644 --- a/README.md +++ b/README.md @@ -40,13 +40,13 @@ Without Composer: extract the library archive and include `require_once '/path/t ## 2. Constants -Define them in a shared settings file that is loaded **both on the form page and in the form handler**: the JavaScript check hash must match in both places. +Define them in a shared settings file that is loaded **both on the form page and in the form handler**: the JavaScript check hash must match in both places. Defining `CLEANTALK_ACCOUNT_EMAIL` and `CLEANTALK_SALT` enables offline protection: when the cloud is unavailable, visitors without the JavaScript check cookie are blocked. Without these constants, requests are allowed while the cloud is unavailable, as in previous versions. ```php define('CLEANTALK_ACCOUNT_EMAIL', 'you@example.com'); // required: CleanTalk account email define('CLEANTALK_SALT', 'f3c9a1e07b2d4c8e9a6b5d0f1e2c3a4b'); // required: secret string, at least 32 characters // define('CLEANTALK_JS_URL', '/cleantalk'); // if the library is outside the web root (see step 4) -// define('CLEANTALK_MODERATE_TIMEOUT', 2); // cloud timeout, seconds (default 2 sec) +// define('CLEANTALK_MODERATE_TIMEOUT', 2); // cloud timeout, seconds (default 2 sec, applied only with offline protection) // define('CLEANTALK_OFFLINE_MODE', true); // testing only: never contact the cloud ``` diff --git a/lib/CleantalkAntispam.php b/lib/CleantalkAntispam.php index e35d68d..2efbca4 100644 --- a/lib/CleantalkAntispam.php +++ b/lib/CleantalkAntispam.php @@ -232,6 +232,20 @@ private function isForcedOfflineMode() return defined('CLEANTALK_OFFLINE_MODE') && CLEANTALK_OFFLINE_MODE; } + /** + * Check if the offline protection is set up by the integration. + * + * The required constants act as the opt-in marker, so integrations made before the offline protection + * keep passing requests through when the moderate server is unreachable. + * + * @return bool + */ + private function isOfflineProtectionConfigured() + { + return defined('CLEANTALK_SALT') && (string)constant('CLEANTALK_SALT') !== '' + && defined('CLEANTALK_ACCOUNT_EMAIL') && (string)constant('CLEANTALK_ACCOUNT_EMAIL') !== ''; + } + /** * Get the moderate server response timeout in seconds. * @@ -298,13 +312,17 @@ private function getCleanTalkResponse() { $http = new Request(); $this->cleantalk_request_data = $this->prepareCleanTalkRequestData(); - $timeout = $this->getModerateTimeout(); $http->setUrl(static::MODERATE_URL) - ->setData($this->cleantalk_request_data) - ->setOptions(array( - CURLOPT_TIMEOUT => $timeout, - CURLOPT_CONNECTTIMEOUT => $timeout, - )); + ->setData($this->cleantalk_request_data); + + // Without the offline fallback a short timeout would only turn slow answers into errors + if ($this->isOfflineProtectionConfigured()) { + $timeout = $this->getModerateTimeout(); + $http->setOptions(array( + CURLOPT_TIMEOUT => $timeout, + CURLOPT_CONNECTTIMEOUT => $timeout, + )); + } $response_raw = $http->request(); $http_code = $http->response instanceof Response ? (int)$http->response->getResponseCode() : 0; @@ -672,8 +690,14 @@ public function handle() $this->cleantalk_response = $this->getCleanTalkResponse(); if ($this->cleantalk_response->error) { + if ($this->isOfflineProtectionConfigured()) { + /** @psalm-suppress InvalidOperand */ + $this->applyOfflineVerdict('The moderate server is unreachable: ' . $this->cleantalk_response->error); + return $this->beforeReturnVerdict(); + } + /** @psalm-suppress InvalidOperand */ - $this->applyOfflineVerdict('The moderate server is unreachable: ' . $this->cleantalk_response->error); + $this->verdict->error = 'CleanTalk moderate server error: ' . $this->cleantalk_response->error; return $this->beforeReturnVerdict(); } @@ -825,6 +849,15 @@ private function setImprovementSuggestion($level, $message, $stack = null) */ private function setOfflineImprovementSuggestions() { + if (!$this->isOfflineProtectionConfigured() && !$this->isForcedOfflineMode()) { + $this->setImprovementSuggestion( + 'average', + 'The offline protection is not configured, so every request is passed through while the moderate server is unreachable. Please, define the CLEANTALK_SALT and CLEANTALK_ACCOUNT_EMAIL constants and print getFrontendHTMLCode() with the access key on the page with the form.', + 'isOfflineProtectionConfigured()' + ); + return; + } + if (static::getJsCheckScriptUrl() === null) { $this->setImprovementSuggestion( 'critical', diff --git a/readme_additional.md b/readme_additional.md index aae3911..5030ce1 100644 --- a/readme_additional.md +++ b/readme_additional.md @@ -64,7 +64,7 @@ The method outputs: To show a warning to visitors without JavaScript, pass `true` as the second argument: `getFrontendHTMLCode($api_key, true)`. -> The legacy call `getFrontendHTMLCode(true)` without a key still works, but outputs only Bot Detector. Without the JavaScript check script, offline protection will block every visitor. +> The legacy call `getFrontendHTMLCode(true)` without a key still works, but outputs only Bot Detector. Without the JavaScript check script, offline protection (if enabled) will block every visitor while the cloud is unavailable. ## Bot Detector and event token @@ -88,13 +88,13 @@ By default, a missing token alone does not block the request. With this method, ## Offline protection -If the CleanTalk cloud is unavailable, the library makes the decision itself based on a JavaScript check. Offline protection is always enabled. +If the CleanTalk cloud is unavailable, the library makes the decision itself based on a JavaScript check. Offline protection is enabled when both `CLEANTALK_SALT` and `CLEANTALK_ACCOUNT_EMAIL` are defined and not empty. Without them, the library works as in previous versions: requests are allowed while the cloud is unavailable, and `verdict->error` contains the reason. **When it is used.** The cloud did not respond within `CLEANTALK_MODERATE_TIMEOUT` seconds, a connection error occurred, the response code was not HTTP 200, or the response was not valid JSON. **How the decision is made.** If the `ct_js_check` cookie matches the expected hash, the request is allowed. If the cookie is missing or does not match, the request is blocked. In both cases the verdict has `offline = true`. -> **Behavior change.** Previously, all requests were allowed when the cloud was unavailable. Now they are blocked without a valid cookie, so the JavaScript check script must be set up. +> **Upgrading.** Existing integrations keep working unchanged. Before defining the constants, print `getFrontendHTMLCode($api_key)` on the form page and make sure `ct-js-check.js` is reachable: otherwise every visitor will be blocked while the cloud is unavailable. ### Constants @@ -102,11 +102,11 @@ Define them with `define()` before using the library, in code that runs both whe | Constant | Required | Description | Default | |---|---|---|---| -| `CLEANTALK_ACCOUNT_EMAIL` | yes | CleanTalk account email, used in the hash. | `''` | -| `CLEANTALK_SALT` | yes | Secret string of at least 32 characters. Without it, the hash depends on the access key only. | `''` | +| `CLEANTALK_ACCOUNT_EMAIL` | yes | CleanTalk account email, used in the hash. Together with `CLEANTALK_SALT` enables offline protection. | `''` | +| `CLEANTALK_SALT` | yes | Secret string of at least 32 characters. Together with `CLEANTALK_ACCOUNT_EMAIL` enables offline protection. | `''` | | `CLEANTALK_JS_URL` | if the library is outside the web root | Public URL of the directory containing `ct-js-check.js`. | auto-detected | -| `CLEANTALK_MODERATE_TIMEOUT` | no | Seconds to wait for the cloud. | `2` | -| `CLEANTALK_OFFLINE_MODE` | no | `true` — never contact the cloud. For testing and emergencies only. | `false` | +| `CLEANTALK_MODERATE_TIMEOUT` | no | Seconds to wait for the cloud. Applied only when offline protection is enabled. | `2` | +| `CLEANTALK_OFFLINE_MODE` | no | `true` — never contact the cloud, always decide by the JavaScript check (works even without the constants above). For testing and emergencies only. | `false` | ### Making `ct-js-check.js` reachable @@ -141,7 +141,7 @@ The library appends the file name and version to the URL itself: `/cleantalk/ct- | `comment` | Reason for the decision. | | `offline` | `true` if the decision was made locally, without the cloud. | | `request_link` | Link to the request in the CleanTalk dashboard (online mode only). | -| `error` | Error that prevented the check (for example, an empty access key). | +| `error` | Error that prevented the check (for example, an empty access key), or the cloud error when offline protection is not enabled. | ## Automatically populated request data @@ -168,7 +168,7 @@ $api_result = $cleantalk_antispam->handle(); error_log($cleantalk_antispam->whatsWrong(true)); ``` -Offline protection suggestions appear when `ct-js-check.js` is not reachable from the browser, when the decision was made without the cloud, or when the `ct_js_check` cookie did not arrive. +Offline protection suggestions appear when the protection is not enabled, when `ct-js-check.js` is not reachable from the browser, when the decision was made without the cloud, or when the `ct_js_check` cookie did not arrive. `getCleanTalkRequestData()` returns the decoded prepared request data for custom diagnostics.