Файл: new_top/core/classes/AntiCheat.php
Строк: 254
<?php
/**
* Anti-cheat for counter hits and inbound clicks
* Self-contained — no external services
*/
declare(strict_types=1);
class AntiCheat
{
/** Min seconds between counted hits from same IP+platform */
public const HIT_INTERVAL = 4;
/** Max hits per IP per platform per calendar day */
public const MAX_HITS_IP_PF_DAY = 120;
/** Max hits per IP across all platforms per hour */
public const MAX_HITS_IP_HOUR = 200;
/** Max unique platforms one IP may inflate per hour */
public const MAX_PF_IP_HOUR = 25;
/** Min seconds between inbound clicks same IP+platform */
public const IN_INTERVAL = 8;
/** Max inbound clicks per IP per platform per day */
public const MAX_IN_IP_PF_DAY = 30;
/** Hits/hosts ratio above this is suspicious (daily) */
public const SUSPECT_HT_HS_RATIO = 40;
/**
* Decide whether this view should be counted.
* Always returns platform row handling is outside — here only allow/deny + reason.
*
* @return array{allow:bool, reason:string}
*/
public static function checkHit(int $platformId, string $ip, string $ua): array
{
if (self::isIpBanned($ip)) {
self::log($platformId, $ip, $ua, 'hit', 'ip_banned');
return ['allow' => false, 'reason' => 'ip_banned'];
}
if (self::isBadUa($ua)) {
self::log($platformId, $ip, $ua, 'hit', 'bad_ua');
return ['allow' => false, 'reason' => 'bad_ua'];
}
if (self::isPrivateOrInvalidIp($ip)) {
self::log($platformId, $ip, $ua, 'hit', 'bad_ip');
return ['allow' => false, 'reason' => 'bad_ip'];
}
$now = time();
$dayStart = mktime(0, 0, 0, (int)date('m'), (int)date('d'), (int)date('Y'));
$hourAgo = $now - 3600;
// Interval: last hit from this IP on this platform
$last = Database::fetchColumn(
'SELECT time FROM ht WHERE pf = ? AND ip = ? ORDER BY id DESC LIMIT 1',
[$platformId, $ip]
);
if ($last && ($now - (int)$last) < self::HIT_INTERVAL) {
self::log($platformId, $ip, $ua, 'hit', 'too_fast');
return ['allow' => false, 'reason' => 'too_fast'];
}
// Daily cap per IP+platform
$dayHits = (int) Database::fetchColumn(
'SELECT COUNT(*) FROM ht WHERE pf = ? AND ip = ? AND time >= ?',
[$platformId, $ip, $dayStart]
);
if ($dayHits >= self::MAX_HITS_IP_PF_DAY) {
self::log($platformId, $ip, $ua, 'hit', 'day_cap');
return ['allow' => false, 'reason' => 'day_cap'];
}
// Global hourly cap per IP
$hourHits = (int) Database::fetchColumn(
'SELECT COUNT(*) FROM ht WHERE ip = ? AND time >= ?',
[$ip, $hourAgo]
);
if ($hourHits >= self::MAX_HITS_IP_HOUR) {
self::log($platformId, $ip, $ua, 'hit', 'hour_cap');
return ['allow' => false, 'reason' => 'hour_cap'];
}
// Too many different platforms from one IP in an hour
$pfHour = (int) Database::fetchColumn(
'SELECT COUNT(DISTINCT pf) FROM ht WHERE ip = ? AND time >= ?',
[$ip, $hourAgo]
);
if ($pfHour >= self::MAX_PF_IP_HOUR) {
self::log($platformId, $ip, $ua, 'hit', 'multi_pf');
return ['allow' => false, 'reason' => 'multi_pf'];
}
return ['allow' => true, 'reason' => 'ok'];
}
/**
* @return array{allow:bool, reason:string}
*/
public static function checkIn(int $platformId, string $ip, string $ua): array
{
if (self::isIpBanned($ip)) {
return ['allow' => false, 'reason' => 'ip_banned'];
}
if (self::isBadUa($ua)) {
return ['allow' => false, 'reason' => 'bad_ua'];
}
$now = time();
$dayStart = mktime(0, 0, 0, (int)date('m'), (int)date('d'), (int)date('Y'));
$last = Database::fetchColumn(
'SELECT time FROM in_log WHERE pf = ? AND ip = ? ORDER BY id DESC LIMIT 1',
[$platformId, $ip]
);
if ($last && ($now - (int)$last) < self::IN_INTERVAL) {
self::log($platformId, $ip, $ua, 'in', 'too_fast');
return ['allow' => false, 'reason' => 'too_fast'];
}
$dayIn = (int) Database::fetchColumn(
'SELECT COUNT(*) FROM in_log WHERE pf = ? AND ip = ? AND time >= ?',
[$platformId, $ip, $dayStart]
);
if ($dayIn >= self::MAX_IN_IP_PF_DAY) {
self::log($platformId, $ip, $ua, 'in', 'day_cap');
return ['allow' => false, 'reason' => 'day_cap'];
}
return ['allow' => true, 'reason' => 'ok'];
}
public static function isBadUa(string $ua): bool
{
$ua = trim($ua);
if ($ua === '' || mb_strlen($ua) < 8) {
return true;
}
$low = mb_strtolower($ua);
$bots = [
'bot', 'spider', 'crawl', 'slurp', 'wget', 'curl', 'python-requests',
'scrapy', 'httpclient', 'libwww', 'java/', 'php/', 'go-http',
'headless', 'selenium', 'puppeteer', 'phantom', 'monitor',
'uptime', 'pingdom', 'gtmetrix', 'preview',
];
foreach ($bots as $b) {
if (strpos($low, $b) !== false) {
return true;
}
}
return false;
}
public static function isPrivateOrInvalidIp(string $ip): bool
{
if ($ip === '' || $ip === '0.0.0.0' || $ip === '127.0.0.1' || $ip === '::1') {
return true;
}
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
// private/reserved — still allow in local/dev if explicitly public check fails
// For tops on public web, private IPs are often proxies misconfig — soft: allow counting but mark
// Strict mode: treat as invalid only when not valid IP at all
if (filter_var($ip, FILTER_VALIDATE_IP) === false) {
return true;
}
}
return false;
}
public static function isIpBanned(string $ip): bool
{
try {
$row = Database::fetch(
'SELECT ip FROM ip_ban WHERE ip = ? AND (until_time = 0 OR until_time > ?) LIMIT 1',
[$ip, time()]
);
return (bool)$row;
} catch (Throwable $e) {
return false;
}
}
public static function banIp(string $ip, string $reason = '', int $hours = 0, int $adminId = 0): void
{
$until = $hours > 0 ? time() + $hours * 3600 : 0;
Database::query(
'INSERT INTO ip_ban (ip, reason, until_time, time_add, admin_id) VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE reason = VALUES(reason), until_time = VALUES(until_time), admin_id = VALUES(admin_id)',
[mb_substr($ip, 0, 45), mb_substr($reason, 0, 200), $until, time(), $adminId]
);
}
public static function unbanIp(string $ip): void
{
Database::query('DELETE FROM ip_ban WHERE ip = ?', [$ip]);
}
public static function log(int $pf, string $ip, string $ua, string $kind, string $reason): void
{
try {
Database::query(
'INSERT INTO cheat_log (pf, ip, ua, kind, reason, time) VALUES (?, ?, ?, ?, ?, ?)',
[$pf, mb_substr($ip, 0, 45), mb_substr($ua, 0, 255), $kind, $reason, time()]
);
} catch (Throwable $e) {
// table may not exist yet on old installs
}
}
/**
* Flag platforms with abnormal ht/hs ratio today
* @return list<array>
*/
public static function suspiciousPlatforms(int $limit = 20): array
{
return Database::fetchAll(
'SELECT id, url, hs, ht, user_id,
CASE WHEN hs > 0 THEN ROUND(ht / hs, 1) ELSE ht END AS ratio
FROM platforms
WHERE mode = 0 AND ban = 0 AND ht > 50
AND (hs = 0 OR (ht / GREATEST(hs, 1)) >= ?)
ORDER BY ratio DESC
LIMIT ' . (int)$limit,
[self::SUSPECT_HT_HS_RATIO]
);
}
/**
* Top IPs by hit volume today (for admin)
*/
public static function topIpsToday(int $limit = 30): array
{
$dayStart = mktime(0, 0, 0, (int)date('m'), (int)date('d'), (int)date('Y'));
return Database::fetchAll(
'SELECT ip, COUNT(*) AS hits, COUNT(DISTINCT pf) AS platforms
FROM ht WHERE time >= ?
GROUP BY ip
ORDER BY hits DESC
LIMIT ' . (int)$limit,
[$dayStart]
);
}
public static function recentBlocks(int $limit = 50): array
{
try {
return Database::fetchAll(
'SELECT * FROM cheat_log ORDER BY id DESC LIMIT ' . (int)$limit
);
} catch (Throwable $e) {
return [];
}
}
public static function reasonLabel(string $code): string
{
switch ($code) {
case 'ip_banned': return 'IP в бане';
case 'bad_ua': return 'Бот / пустой UA';
case 'bad_ip': return 'Некорректный IP';
case 'too_fast': return 'Слишком часто';
case 'day_cap': return 'Дневной лимит';
case 'hour_cap': return 'Часовой лимит';
case 'multi_pf': return 'Много площадок с IP';
default: return $code;
}
}
}