diff --git a/assets/js/tinkoff-antifraud.js b/assets/js/tinkoff-antifraud.js new file mode 100644 index 000000000..e9c9002f7 --- /dev/null +++ b/assets/js/tinkoff-antifraud.js @@ -0,0 +1,104 @@ +(() => { + const COOKIE_NAME = 'leyka_tk_af'; + const TTL_DAYS = 7; + + function setCookie(name, value, days) { + const expires = new Date(Date.now() + days * 864e5).toUTCString(); + document.cookie = `${name}=${encodeURIComponent(value)}; Expires=${expires}; Path=/; SameSite=Lax`; + } + + function base64urlEncodeUtf8(str) { + const bytes = new TextEncoder().encode(str); + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + const b64 = btoa(binary); + return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); + } + + function getCookieNames() { + const raw = document.cookie || ''; + if (!raw.trim()) return ''; + const names = raw + .split(';') + .map((p) => p.split('=')[0].trim()) + .filter(Boolean); + + const uniq = Array.from(new Set(names)); + return uniq.join(',').slice(0, 100); + } + + async function sha256Hex(str) { + if (window.crypto?.subtle?.digest) { + const enc = new TextEncoder().encode(str); + const buf = await crypto.subtle.digest('SHA-256', enc); + return Array.from(new Uint8Array(buf)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + } + + let h = 0; + for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) | 0; + return String(h >>> 0); + } + + function getOrCreateDeviceId() { + const key = 'leyka_tk_device_id'; + let id = localStorage.getItem(key); + + if (!id) { + id = crypto?.randomUUID + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(16).slice(2)}`; + localStorage.setItem(key, id); + } + + return id.slice(0, 100); + } + + function detectOS() { + const uaData = navigator.userAgentData; + if (uaData && Array.isArray(uaData.brands)) { + if (typeof uaData.platform === 'string' && uaData.platform) { + return uaData.platform.slice(0, 100); + } + } + + const ua = (navigator.userAgent || '').toLowerCase(); + + if (ua.includes('windows')) return 'Windows'; + if (ua.includes('mac os') || ua.includes('macintosh')) return 'macOS'; + if (ua.includes('android')) return 'Android'; + if (ua.includes('iphone') || ua.includes('ipad') || ua.includes('ipod')) return 'iOS'; + if (ua.includes('cros')) return 'Chrome OS'; + if (ua.includes('linux')) return 'Linux'; + return 'Unknown'; + } + + async function main() { + try { + const deviceId = getOrCreateDeviceId(); + const os = detectOS(); + const referrer = (document.referrer || '').slice(0, 100); + + const cookieNames = getCookieNames(); + const cookieHash = (await sha256Hex(document.cookie || '')).slice(0, 100); + + const payload = { + v: 1, + deviceId, + os, + referrer, + cookieNames, + cookieHash, + }; + + setCookie(COOKIE_NAME, base64urlEncodeUtf8(JSON.stringify(payload)), TTL_DAYS); + } catch (_) { + + } + } + + main(); +})(); \ No newline at end of file diff --git a/gateways/tinkoff/leyka-class-tinkoff-gateway.php b/gateways/tinkoff/leyka-class-tinkoff-gateway.php index d41165939..3ced12dfa 100644 --- a/gateways/tinkoff/leyka-class-tinkoff-gateway.php +++ b/gateways/tinkoff/leyka-class-tinkoff-gateway.php @@ -16,7 +16,109 @@ protected function _set_attributes() { $this->_receiver_types = ['legal',]; $this->_may_support_recurring = true; + // === AntiFraud ADDITION START: enqueue JS on frontend (once) === + static $af_hooked = false; + if (!$af_hooked) { + add_action('wp_enqueue_scripts', [$this, 'enqueue_antifraud_assets']); + $af_hooked = true; + } + // === AntiFraud ADDITION END === + } + + // === AntiFraud ADDITION START: enqueue JS file === + public function enqueue_antifraud_assets() { + if (is_admin()) { + return; + } + + wp_enqueue_script( + 'leyka-tinkoff-antifraud', + LEYKA_PLUGIN_BASE_URL . 'assets/js/tinkoff-antifraud.js', + [], + defined('LEYKA_VERSION') ? LEYKA_VERSION : false, + true + ); + } + // === AntiFraud ADDITION END === + + // === AntiFraud ADDITION START: parse cookie payload from JS === + protected function get_antifraud_payload() { + if (empty($_COOKIE['leyka_tk_af'])) { + return []; + } + + $raw = sanitize_text_field(wp_unslash($_COOKIE['leyka_tk_af'])); + + // base64url -> base64 + $b64 = strtr($raw, '-_', '+/'); + $pad = strlen($b64) % 4; + if ($pad) { + $b64 .= str_repeat('=', 4 - $pad); + } + + $json = base64_decode($b64, true); + if (!$json) { + return []; + } + + $data = json_decode($json, true); + if (!is_array($data)) { + return []; + } + + // DATA limits: key <= 20 chars, value <= 100 chars (we keep values <= 100) + $out = []; + $out['deviceId'] = !empty($data['deviceId']) ? substr((string)$data['deviceId'], 0, 100) : ''; + $out['os'] = !empty($data['os']) ? substr((string)$data['os'], 0, 100) : ''; + $out['referrer'] = !empty($data['referrer']) ? substr((string)$data['referrer'], 0, 100) : ''; + $out['cookieHash'] = !empty($data['cookieHash']) ? substr((string)$data['cookieHash'], 0, 100) : ''; + + return $out; } + // === AntiFraud ADDITION END === + + // === AntiFraud ADDITION START: apply fields to Init params === + protected function apply_antifraud_to_init_params(array &$params) { + if (empty($params['DATA']) || !is_array($params['DATA'])) { + $params['DATA'] = []; + } + + // IP + $ip = leyka_get_client_ip(); + if ($ip) { + $params['DATA']['ClientIP'] = substr((string)$ip, 0, 100); + } + + $af = $this->get_antifraud_payload(); + + // Referrer (prefer JS; fallback server) + if (!empty($af['referrer'])) { + $params['DATA']['Referrer'] = sanitize_text_field($af['referrer']); + } else { + $server_ref = wp_get_raw_referer(); + if ($server_ref) { + $params['DATA']['Referrer'] = substr((string)$server_ref, 0, 100); + } + } + + // Device ID + if (!empty($af['deviceId'])) { + $params['DATA']['DeviceId'] = sanitize_text_field($af['deviceId']); + } + + // OS (top-level + duplicate in DATA for visibility in logs) + if (!empty($af['os'])) { + $os = sanitize_text_field($af['os']); + $params['DeviceOs'] = substr($os, 0, 100); + $params['DATA']['OS'] = substr($os, 0, 100); + } + + // Cookies: hash only (send as random_cookie) + if (!empty($af['cookieHash'])) { + $params['DATA']['random_cookie'] = sanitize_text_field($af['cookieHash']); + } + } + // === AntiFraud ADDITION END === protected function _set_options_defaults() { @@ -95,11 +197,17 @@ public function do_recurring_donation(Leyka_Donation_Base $init_recurring_donati leyka_options()->opt($this->_id.'_password') ); - $api->init([ + // === AntiFraud ADDITION START: build params for Init + enrich === + $init_params = [ 'OrderId' => $new_recurring_donation->id, 'Amount' => 100 * absint($new_recurring_donation->amount), 'DATA' => ['Email' => $init_recurring_donation->donor_email,], - ]); + ]; + + $this->apply_antifraud_to_init_params($init_params); + + $api->init($init_params); + // === AntiFraud ADDITION END === if($api->error){ $this->_handle_donation_failure($new_recurring_donation, $api); @@ -162,6 +270,10 @@ public function process_form($gateway_id, $pm_id, $donation_id, $form_data) { } + // === AntiFraud ADDITION START: enrich Init params before calling Init === + $this->apply_antifraud_to_init_params($params); + // === AntiFraud ADDITION END === + $api->init($params); if($api->error){ @@ -478,4 +590,4 @@ public function has_recurring_support() { function leyka_add_gateway_tinkoff() { // Use named function to leave a possibility to remove/replace it on the hook leyka_add_gateway(Leyka_Tinkoff_Gateway::get_instance()); } -add_action('leyka_init_actions', 'leyka_add_gateway_tinkoff'); \ No newline at end of file +add_action('leyka_init_actions', 'leyka_add_gateway_tinkoff');