import type { IHttpClient } from '../http'; import type { Track } from '../report'; import { getOriginalLocalStorage } from '@jolibox/common'; getOriginalLocalStorage(); export type AdsDisplayPermission = | 'BLOCK_INITIAL' | 'BANNED_FOR_SESSION' | 'HOLDING_HIGH_FREQ' | 'NETWORK_NOT_OK' | 'WAITING_BANNED_RELEASE' | 'BANNED_FOR_TIME' | 'ALLOWED'; interface CallAdsHistory { timestamp: number; type: 'reward' | 'interstitial'; } export interface IAdsAntiCheatingConfig { maxAllowedAdsForTime?: number; // default 8 banForTimeThreshold?: number; // default 60000 (1 minute) bannedReleaseTime?: number; // default 60000 (1 minute), must be >= checkingTimeThreshold initialThreshold?: number; // default 2000 maxAllowedAdsForSession?: number; // default 200 banForSessionThreshold?: number; // default 600000 (10 minutes) highFreqThreshold?: number; // default 2000 (2s) } declare global { interface Window { __joliboxLocalStorage__: Storage; } } export class AdsAntiCheating { private checkShouldBanInitial: ReturnType; private checkShouldBanHighFreq: ReturnType; private checkShouldBanForTime: ReturnType; private checkShouldBanForSession: ReturnType; constructor( readonly track: Track, readonly httpClient: IHttpClient, readonly checkNetwork: () => boolean, config?: IAdsAntiCheatingConfig ) { const maxAllowedAdsForTime = config?.maxAllowedAdsForTime ?? 8; const banForTimeThreshold = config?.banForTimeThreshold ?? 60000; const bannedReleaseTime = config?.bannedReleaseTime ?? 60000; const maxAllowedAdsForSession = config?.maxAllowedAdsForSession ?? 200; const banForSessionThreshold = config?.banForSessionThreshold ?? 600000; const initialThreshold = config?.initialThreshold ?? 2000; const highFreqThreshold = config?.highFreqThreshold ?? 2000; this.checkShouldBanInitial = createInitialStrategy(initialThreshold); this.checkShouldBanHighFreq = createHighFreqStrategy(highFreqThreshold); this.checkShouldBanForTime = createBanForTimeStrategy( maxAllowedAdsForTime, banForTimeThreshold, bannedReleaseTime ); this.checkShouldBanForSession = createBanForSessionStrategy( maxAllowedAdsForSession, banForSessionThreshold ); } public checkAdsDisplayPermission: (type: 'reward' | 'interstitial') => { reason: AdsDisplayPermission; info?: { count: number }; } = (type) => { if (!this.checkNetwork()) { return { reason: 'NETWORK_NOT_OK' }; } const shouldBanInitial = this.checkShouldBanInitial(); if (shouldBanInitial) { return { reason: shouldBanInitial }; } const shouldBanForSession = this.checkShouldBanForSession(type); if (shouldBanForSession) { return { reason: shouldBanForSession }; } const shouldBanForHighFreq = this.checkShouldBanHighFreq(type); if (shouldBanForHighFreq) { return shouldBanForHighFreq; } const shouldBanForTime = this.checkShouldBanForTime(type); if (shouldBanForTime) { return { reason: shouldBanForTime }; } return { reason: 'ALLOWED' }; }; /** * Report an ad display permission issue. * Report to both event tracking system and the business backend system. * @param reason */ public report(reason: AdsDisplayPermission, count?: number) { const info = count ? { reason, count } : { reason }; this.track('PreventAdsCheating', info); this.httpClient.post('/api/base/app-event', { data: { eventType: 'PREVENT_ADS_CHEATING', adsInfo: info } }); } } const createBanForSessionStrategy = (maxAllowedAdsForSession: number, banForSessionThreshold: number) => { let isBannedForSession = false; let callAdsTimestampsForSession: CallAdsHistory[] = []; /** * Check if we should ban this user for the whole session. By default, if user makes more than 200 calls in 10 minutes, ban for the session. * @param type * @returns */ const check = (type: 'reward' | 'interstitial') => { if (isBannedForSession) { return 'BANNED_FOR_SESSION'; } const now = Date.now(); // keep track of call timestamps callAdsTimestampsForSession = callAdsTimestampsForSession.concat({ timestamp: now, type }); // if only 1 call, no need to check if (callAdsTimestampsForSession.length === 1) { return undefined; } // keep last x timestamps if (callAdsTimestampsForSession.length > maxAllowedAdsForSession) { callAdsTimestampsForSession = callAdsTimestampsForSession.slice(-maxAllowedAdsForSession); } // if less than x seconds, ban for session if (callAdsTimestampsForSession.length === maxAllowedAdsForSession) { const diffFromFirst = now - callAdsTimestampsForSession[0].timestamp; if (diffFromFirst <= banForSessionThreshold) { isBannedForSession = true; return 'BANNED_FOR_SESSION'; } } }; return check; }; const createBanForTimeStrategy = ( maxAllowedAdsForTime: number, banForTimeThreshold: number, bannedReleaseTime: number ) => { let releaseBannedForTimeTimeout: number | null = null; const setReleaseBannedForTime = (timeout: number) => { if (releaseBannedForTimeTimeout) { window.clearTimeout(releaseBannedForTimeTimeout); releaseBannedForTimeTimeout = null; } releaseBannedForTimeTimeout = window.setTimeout(() => { isBannedForTime.value = false; releaseBannedForTimeTimeout = null; }, timeout); }; const TIMESTAMP_STORAGE_KEY = 'jolibox-sdk-ads-callbreak-timestamps'; const BAN_FOR_TIME_KEY = 'jolibox-sdk-ads-ban-for-time'; const callAdsHistory = { _records: [] as CallAdsHistory[], init() { try { const fromStorage = JSON.parse(window.__joliboxLocalStorage__.getItem(TIMESTAMP_STORAGE_KEY) ?? '[]'); if (Array.isArray(fromStorage)) { this._records = fromStorage; } else { this._records = []; } } catch { this._records = []; } }, get values() { return this._records; }, set values(value: CallAdsHistory[]) { try { window.__joliboxLocalStorage__.setItem(TIMESTAMP_STORAGE_KEY, JSON.stringify(value)); } catch { // console.error('Failed to save timestamps'); // do nothing } this._records = value; } }; const isBannedForTime = { _record: false, init() { try { const fromStorage = JSON.parse(window.__joliboxLocalStorage__.getItem(BAN_FOR_TIME_KEY) ?? 'false'); if (typeof fromStorage === 'boolean') { this._record = fromStorage; } } catch { this._record = false; // default value } if (this._record) { const lastCall: CallAdsHistory | undefined = callAdsHistory.values[callAdsHistory.values.length - 1]; const timeDiff = lastCall ? Date.now() - lastCall.timestamp : bannedReleaseTime; if (timeDiff <= bannedReleaseTime && timeDiff > 0) { // within the banned release time window setReleaseBannedForTime(timeDiff); } else { this._record = false; } } }, get value() { return this._record; }, set value(value: boolean) { try { window.__joliboxLocalStorage__.setItem(BAN_FOR_TIME_KEY, JSON.stringify(value)); } catch { // do nothing } this._record = value; } }; callAdsHistory.init(); isBannedForTime.init(); /** * Check if we should ban this user for a period of time. By default, if user makes more than 8 calls in 1 minute, ban for 1 minute. * After 1 minute, user can make calls again. * @param type * @returns */ const check = (type: 'reward' | 'interstitial') => { if (isBannedForTime.value) { return 'WAITING_BANNED_RELEASE'; } const now = Date.now(); // keep track of call timestamps callAdsHistory.values = callAdsHistory.values.concat({ timestamp: now, type }); // if only 1 call, no need to check if (callAdsHistory.values.length === 1) { return undefined; } // keep last x timestamps if (callAdsHistory.values.length > maxAllowedAdsForTime) { callAdsHistory.values = callAdsHistory.values.slice(-maxAllowedAdsForTime); } // if less than x seconds, ban for time if (callAdsHistory.values.length === maxAllowedAdsForTime) { const diffFromFirst = now - callAdsHistory.values[0].timestamp; if (diffFromFirst <= banForTimeThreshold) { isBannedForTime.value = true; setReleaseBannedForTime(bannedReleaseTime); return 'BANNED_FOR_TIME'; } } }; return check; }; const createInitialStrategy = (initialThreshold: number) => { const initTimestamp = Date.now(); /** * Check if we should ban for initial call. By default, if user makes a call less than 2 seconds after the first call, block. * @returns */ const check = () => { const now = Date.now(); const diffFromInit = now - initTimestamp; // if initial call less than x seconds, block if (diffFromInit <= initialThreshold) { return 'BLOCK_INITIAL'; } }; return check; }; const createHighFreqStrategy = (highFreqThreshold = 2000) => { let highFreqCounter = 0; let highFreqTimer: number | null = null; /** * Check if we should ban for high frequency calling. If user makes a call more than 1 times in x seconds, block. * This only works for reward ads. * * @param type * @returns */ const check = (type: 'reward' | 'interstitial') => { if (type === 'reward') { if (highFreqTimer) { highFreqCounter += 1; return { reason: 'HOLDING_HIGH_FREQ' as const, info: { count: highFreqCounter } }; } else { highFreqTimer = window.setTimeout(() => { if (highFreqTimer) { window.clearTimeout(highFreqTimer); highFreqTimer = 0; highFreqCounter = 0; } }, highFreqThreshold); } } }; return check; };