Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions assets/js/tinkoff-antifraud.js
Original file line number Diff line number Diff line change
@@ -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();
})();
118 changes: 115 additions & 3 deletions gateways/tinkoff/leyka-class-tinkoff-gateway.php
Original file line number Diff line number Diff line change
Expand Up @@ -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() {

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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){
Expand Down Expand Up @@ -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');
add_action('leyka_init_actions', 'leyka_add_gateway_tinkoff');