diff --git a/assets/js/ads-popup-guard.ts b/assets/js/ads-popup-guard.ts new file mode 100644 index 00000000..8f654954 --- /dev/null +++ b/assets/js/ads-popup-guard.ts @@ -0,0 +1,158 @@ +export const AD_POPUP_CAP_DURATION_MS = 20 * 60 * 1000 +export const IN_PAGE_PUSH_SEARCH_PARAM_PREFIXES = ['inpage.'] as const + +export type PopupOpenKind = 'popunder' | 'in-page-push' +export type PopupClassification = { + kind: PopupOpenKind + hostnames?: string[] + searchParamPrefixes?: readonly string[] +} + +export type AdPopupCapLogDetails = { + lastPopupAt: number | null + cappedUntil: number | null + remainingMs: number | null +} + +export type PopupGuardDecision = { + event: 'allow-in-page-push' | 'allow-popunder' | 'block-capped-popunder' + shouldAllow: boolean + shouldRecordPopupAt: boolean + capLogDetails?: AdPopupCapLogDetails + cappedUntil?: number +} + +export type TrustedPopupBypassDecision = { + shouldBypassCurrentOpen: boolean + nextShouldBypass: boolean +} + +function hostnameMatches(hostname: string, allowedHostnames: string[]): boolean { + return allowedHostnames.some(allowedHostname => { + return hostname === allowedHostname || hostname.endsWith(`.${allowedHostname}`) + }) +} + +function hasMatchingSearchParamPrefix(parsedUrl: URL, searchParamPrefixes: readonly string[]): boolean { + for (const searchParamKey of parsedUrl.searchParams.keys()) { + if (searchParamPrefixes.some(prefix => searchParamKey.startsWith(prefix))) { + return true + } + } + + return false +} + +function matchesPopupClassification(parsedUrl: URL, popupClassification: PopupClassification): boolean { + if (popupClassification.hostnames && hostnameMatches(parsedUrl.hostname, popupClassification.hostnames)) { + return true + } + + if (popupClassification.searchParamPrefixes && hasMatchingSearchParamPrefix(parsedUrl, popupClassification.searchParamPrefixes)) { + return true + } + + return false +} + +export function getTrustedPopupBypassDecision(shouldBypassNextWindowOpenGuard: boolean): TrustedPopupBypassDecision { + return { + shouldBypassCurrentOpen: shouldBypassNextWindowOpenGuard, + nextShouldBypass: false + } +} + +export function getPopupOpenKind( + requestedUrl: string | null, + { + baseUrl, + popupClassifications + }: { + baseUrl: string + popupClassifications: readonly PopupClassification[] + } +): PopupOpenKind { + if (!requestedUrl) { + return 'popunder' + } + + try { + const parsedUrl = new URL(requestedUrl, baseUrl) + + for (const popupClassification of popupClassifications) { + if (matchesPopupClassification(parsedUrl, popupClassification)) { + return popupClassification.kind + } + } + } catch { + // Ignore malformed vendor URLs and keep the default popunder classification. + } + + return 'popunder' +} + +export function isAdPopupCapActive( + lastPopupAt: number | null, + now: number, + capDurationMs = AD_POPUP_CAP_DURATION_MS +): boolean { + return lastPopupAt !== null && now - lastPopupAt < capDurationMs +} + +export function getAdPopupCapLogDetails( + lastPopupAt: number | null, + now: number, + capDurationMs = AD_POPUP_CAP_DURATION_MS +): AdPopupCapLogDetails { + if (lastPopupAt === null) { + return { + lastPopupAt: null, + cappedUntil: null, + remainingMs: null + } + } + + const cappedUntil = lastPopupAt + capDurationMs + + return { + lastPopupAt, + cappedUntil, + remainingMs: Math.max(0, cappedUntil - now) + } +} + +export function getPopupGuardDecision({ + popupOpenKind, + lastPopupAt, + now, + capDurationMs = AD_POPUP_CAP_DURATION_MS +}: { + popupOpenKind: PopupOpenKind + lastPopupAt: number | null + now: number + capDurationMs?: number +}): PopupGuardDecision { + if (popupOpenKind === 'in-page-push') { + return { + event: 'allow-in-page-push', + shouldAllow: true, + shouldRecordPopupAt: false + } + } + + if (isAdPopupCapActive(lastPopupAt, now, capDurationMs)) { + return { + event: 'block-capped-popunder', + shouldAllow: false, + shouldRecordPopupAt: false, + capLogDetails: getAdPopupCapLogDetails(lastPopupAt, now, capDurationMs) + } + } + + return { + event: 'allow-popunder', + shouldAllow: true, + shouldRecordPopupAt: true, + cappedUntil: now + capDurationMs + } +} diff --git a/components/layout/FeedbackButton.vue b/components/layout/FeedbackButton.vue index 38eb8a3f..0010e664 100644 --- a/components/layout/FeedbackButton.vue +++ b/components/layout/FeedbackButton.vue @@ -3,7 +3,7 @@ import { project } from '@/config/project' function handleClick(event: Event) { - window.open(`https://feedback.${project.urls.production.hostname}`, '_blank') + openTrustedWindow(`https://feedback.${project.urls.production.hostname}`, '_blank') } diff --git a/components/pages/posts/post/PostSource.vue b/components/pages/posts/post/PostSource.vue index 7aa33afe..019a1aa9 100644 --- a/components/pages/posts/post/PostSource.vue +++ b/components/pages/posts/post/PostSource.vue @@ -90,7 +90,7 @@ return } - window.open(url, '_blank') + openTrustedWindow(url, '_blank') } function onMenuOpen() { diff --git a/composables/useAdvertisements.ts b/composables/useAdvertisements.ts index 17065965..887b4ec3 100644 --- a/composables/useAdvertisements.ts +++ b/composables/useAdvertisements.ts @@ -1,148 +1,446 @@ import { default as randomWeightedChoice } from 'random-weighted-choice' +import { + AD_POPUP_CAP_DURATION_MS, + IN_PAGE_PUSH_SEARCH_PARAM_PREFIXES, + getAdPopupCapLogDetails as getAdPopupCapLogDetailsPure, + getPopupGuardDecision, + getPopupOpenKind as getPopupOpenKindPure, + getTrustedPopupBypassDecision, + isAdPopupCapActive as isAdPopupCapActivePure, + type PopupClassification +} from '../assets/js/ads-popup-guard' + +const AD_LAST_POPUP_AT_STORAGE_KEY = 'ads-last-popup-at' +const AD_TRUSTED_WINDOW_OPEN_BYPASS_STATE_KEY = 'ads-trusted-window-open-bypass-next' +const INTEGER_TIMESTAMP_REGEX = /^\d+$/ +const AD_SCRIPT_ATTRIBUTES = { + async: false, + defer: true, + crossorigin: 'anonymous' as const +} + +type WindowOpenArgs = Parameters +type WindowOpenResult = ReturnType +type WeightedAd = { + id: string + weight: number +} +type AdProvider = { + name: string + ads: WeightedAd[] + popupClassification?: PopupClassification +} + +const POPUNDER_AD_PROVIDERS: AdProvider[] = [ + /** + * ExoClick + * Pros: + * Cons: + */ + // { + // name: 'ExoClick', + // ads: [ + // { + // id: '', + // weight: 1 + // } + // ] + // }, + /** + * Adsession + * Pros: + * Cons: + */ + // { + // name: 'Adsession', + // ads: [ + // { + // id: '/js/popunder.js?v=7', + // weight: 1 + // } + // ] + // }, + /** + * HilltopAds + * Pros: Good min payout + * Cons: Not fixed CPM, Low Revenue (70) + */ + { + name: 'HilltopAds', + ads: [ + { + id: 'https://ellipticaltrack.com/c.D/9v6/bW2/5aleSRW/Qj9SNojrA/zWMxTuk_zvNoiJ0S2kMgDBMux_OXTCMU3Z', + weight: 1 + } + ] + }, + /** + * Clickadu + * Pros: Good CPM (2.1) + * Cons: Low revenue (70), Does not count visits well, (!!!) Clears console + */ + { + name: 'Clickadu', + ads: [ + { + id: '/js/popunder2.js?v=10', + weight: 1 + } + ] + }, + /** + * AdMaven + * Pros: + * Cons: Does not open in a new tab, Possible malware: ads open requests to social media login?? + */ + // { + // name: 'AdMaven', + // ads: [ + // { + // id: 'https://d3pk1qkob3uzgp.cloudfront.net/?kqkpd=1171073', + // weight: 1 + // } + // ] + // }, + /** + * AdsCarat + * Pros: Great CPM (2.5) + * Cons: Low Revenue (25) | Does not count visits well | Reloads website once? + */ + // { + // name: 'AdsCarat', + // ads: [ + // { + // id: 'https://hp.scrannyplacebo.com/rMGqiS1acWcIq4LyI/oQRmJ', + // weight: 1 + // } + // ] + // } +] + +const PUSH_AD_PROVIDERS: AdProvider[] = [ + /** + * PartnersHouse + * Pros: + * Cons: Low revenue (17) + */ + { + name: 'PartnersHouse', + popupClassification: { + kind: 'in-page-push', + hostnames: ['hotbsizovu.today', 'hotsoz.com'], + searchParamPrefixes: IN_PAGE_PUSH_SEARCH_PARAM_PREFIXES + }, + ads: [ + { + id: 'https://hotbsizovu.today/process.js?id=1300335215&p1=sub1&p2=sub2&p3=sub3&p4=sub4', + weight: 0.15 + } + ] + }, + /** + * HilltopAds + * Pros: + * Cons: Very Low Revenue (1.96) + */ + // { + // name: 'HilltopAds', + // ads: [ + // { + // id: '\\/\\/ellipticaltrack.com\\/b\\/XeV.sad\\/GJlb0jYvWxcR\\/HewmG9ou\\/ZWUXlukZPMTJY_yMOQTBQe5VMsjVI\\/tuNbjOIh5MNDDpkryvMSwO', + // weight: 0.15 + // } + // ] + // }, + /** + * Clickadu + * Pros: + * Cons: Low Revenue (4.64) + */ + // { + // name: 'Clickadu', + // ads: [ + // { + // id: '//guidepaparazzisurface.com/bultykh/ipp24/7/bazinga/2065744', + // weight: 0.15 + // } + // ] + // }, + /** + * AdsCarat + * Pros: + * Cons: Extremely low revenue (0.50) + */ + // { + // name: 'AdsCarat', + // ads: [ + // { + // id: '//jn.astelicbanes.com/sgC9H1j3tpX/121206', + // weight: 0.15 + // } + // ] + // }, + /** + * EvaDav + * Pros: Fixed weekly pay () + * Cons: + */ + { + name: 'EvaDav', + popupClassification: { + kind: 'in-page-push', + hostnames: ['udzpel.com'], + searchParamPrefixes: IN_PAGE_PUSH_SEARCH_PARAM_PREFIXES + }, + ads: [ + { + id: 'https://udzpel.com/pw/waWQiOjExOTMwMzUsInNpZCI6MTQwNzY1NSwid2lkIjo2ODMzODcsInNyYyI6Mn0=eyJ.js', + weight: 1 + } + ] + } +] + +function getProviderAds(providers: AdProvider[]): WeightedAd[] { + return providers.flatMap(provider => provider.ads) +} + +function logAdPopupGuard(event: string, details?: Record) { + if (!import.meta.dev) { + return + } + + console.debug('[ads-popup-guard]', { + event, + ...details + }) +} + +function getRequestedUrl(args: WindowOpenArgs): string | null { + const [requestedUrl] = args + + return typeof requestedUrl === 'string' ? requestedUrl : null +} + +function createBlockedAdPopupError(reason: 'frequency-cap' | 'browser-blocked'): Error { + return new Error(`Ad popup blocked: ${reason}`) +} + +const PUSH_POPUP_CLASSIFICATIONS: readonly PopupClassification[] = PUSH_AD_PROVIDERS + .flatMap(provider => provider.popupClassification ? [provider.popupClassification] : []) export default function () { const popunderScript = useState('popunder-script', () => '') const pushScript = useState('push-notification-script', () => '') + const isPopupGuardInstalled = useState('ads-popup-guard-installed', () => false) + const isPopupGuardArmed = useState('ads-popup-guard-armed', () => false) + const lastAdPopupAtInMemory = useState('ads-last-popup-at-in-memory', () => null) + const shouldBypassNextWindowOpenGuard = useState(AD_TRUSTED_WINDOW_OPEN_BYPASS_STATE_KEY, () => false) - const popunderAds = [ - /** - * ExoClick - * Pros: - * Cons: - */ - // { - // id: '', - // weight: 1, - // }, - /** - * Adsession - * Pros: - * Cons: - */ - // { - // id: '/js/popunder.js?v=7', - // weight: 1, - // }, - /** - * HilltopAds - * Pros: Good min payout - * Cons: Not fixed CPM, Low Revenue (70) - */ - { - id: 'https:////ellipticaltrack.com/c.D/9v6/bW2/5aleSRW/Qj9SNojrA/zWMxTuk_zvNoiJ0S2kMgDBMux_OXTCMU3Z', - weight: 1 - }, - /** - * Clickadu - * Pros: Good CPM (2.1) - * Cons: Low revenue (70), Does not count visits well, (!!!) Clears console - */ - { - id: '/js/popunder2.js?v=10', - weight: 1 + if (!import.meta.client) { + return + } + + function parseStoredLastPopupAt(rawLastPopupAt: string, now: number): number | null { + const normalizedRawLastPopupAt = rawLastPopupAt.trim() + + if (!INTEGER_TIMESTAMP_REGEX.test(normalizedRawLastPopupAt)) { + return null } - /** - * AdMaven - * Pros: - * Cons: Does not open in a new tab, Possible malware: ads open requests to social media login?? - */ - // { - // id: 'https://d3pk1qkob3uzgp.cloudfront.net/?kqkpd=1171073', - // weight: 1, - // }, - /** - * AdsCarat - * Pros: Great CPM (2.5) - * Cons: Low Revenue (25) | Does not count visits well | Reloads website once? - */ - // { - // id: 'https://hp.scrannyplacebo.com/rMGqiS1acWcIq4LyI/oQRmJ', - // weight: 1 - // } - ] - const pushAds = [ - /** - * PartnersHouse - * Pros: - * Cons: Low revenue (17) - */ - { - id: 'https://hotbsizovu.today/process.js?id=1300335215&p1=sub1&p2=sub2&p3=sub3&p4=sub4', - weight: 0.15 - }, - /** - * HilltopAds - * Pros: - * Cons: Very Low Revenue (1.96) - */ - // { - // id: '\\/\\/ellipticaltrack.com\\/b\\/XeV.sad\\/GJlb0jYvWxcR\\/HewmG9ou\\/ZWUXlukZPMTJY_yMOQTBQe5VMsjVI\\/tuNbjOIh5MNDDpkryvMSwO', - // weight: 0.15, - // }, - /** - * Clickadu - * Pros: - * Cons: Low Revenue (4.64) - */ - // { - // id: '//guidepaparazzisurface.com/bultykh/ipp24/7/bazinga/2065744', - // weight: 0.15, - // }, - /** - * AdsCarat - * Pros: - * Cons: Extremely low revenue (0.50) - */ - // { - // id: '//jn.astelicbanes.com/sgC9H1j3tpX/121206', - // weight: 0.15 - // }, - /** - * EvaDav - * Pros: Fixed weekly pay () - * Cons: - */ - { - id: 'https://udzpel.com/pw/waWQiOjExOTMwMzUsInNpZCI6MTQwNzY1NSwid2lkIjo2ODMzODcsInNyYyI6Mn0=eyJ.js', - weight: 1 + const parsedLastPopupAt = Number(normalizedRawLastPopupAt) + + if ( + !Number.isSafeInteger(parsedLastPopupAt) + || parsedLastPopupAt <= 0 + || parsedLastPopupAt > now + ) { + return null + } + + return parsedLastPopupAt + } + + function getLastAdPopupAt(now = Date.now()): number | null { + let resolvedLastPopupAt: number | null = null + const inMemoryLastPopupAt = lastAdPopupAtInMemory.value + + if (inMemoryLastPopupAt !== null) { + if ( + Number.isSafeInteger(inMemoryLastPopupAt) + && inMemoryLastPopupAt > 0 + && inMemoryLastPopupAt <= now + ) { + resolvedLastPopupAt = inMemoryLastPopupAt + } else { + // Reset invalid or future in-memory values so they do not over-block. + lastAdPopupAtInMemory.value = null + } } - ] - // Load popunder ad if not already loaded + try { + const rawLastPopupAt = window.localStorage.getItem(AD_LAST_POPUP_AT_STORAGE_KEY) + + if (rawLastPopupAt) { + const parsedLastPopupAt = parseStoredLastPopupAt(rawLastPopupAt, now) + + if ( + parsedLastPopupAt !== null + && (resolvedLastPopupAt === null || parsedLastPopupAt > resolvedLastPopupAt) + ) { + resolvedLastPopupAt = parsedLastPopupAt + } + } + } catch { + // Ignore storage failures and use in-memory fallback + } + + lastAdPopupAtInMemory.value = resolvedLastPopupAt + + return resolvedLastPopupAt + } + + function recordAdPopupOpened(at = Date.now()) { + lastAdPopupAtInMemory.value = at + + try { + window.localStorage.setItem(AD_LAST_POPUP_AT_STORAGE_KEY, String(at)) + } catch { + // Ignore storage failures and keep the in-memory fallback + } + } + + function isAdPopupCapActive(now = Date.now()): boolean { + const lastPopupAt = getLastAdPopupAt(now) + + return isAdPopupCapActivePure(lastPopupAt, now) + } + + function getAdPopupCapLogDetails(now = Date.now()): Record { + const lastPopupAt = getLastAdPopupAt(now) + + return getAdPopupCapLogDetailsPure(lastPopupAt, now) + } + + if (!isPopupGuardInstalled.value) { + const originalWindowOpen = window.open.bind(window) + + window.open = (...args: WindowOpenArgs): WindowOpenResult => { + const requestedUrl = getRequestedUrl(args) + const trustedPopupBypassDecision = getTrustedPopupBypassDecision(shouldBypassNextWindowOpenGuard.value) + + if (trustedPopupBypassDecision.shouldBypassCurrentOpen) { + shouldBypassNextWindowOpenGuard.value = trustedPopupBypassDecision.nextShouldBypass + + logAdPopupGuard('trusted-open-bypass', { + requestedUrl + }) + + return originalWindowOpen(...args) + } + + if (!isPopupGuardArmed.value) { + return originalWindowOpen(...args) + } + + const popupOpenKind = getPopupOpenKindPure(requestedUrl, { + baseUrl: window.location.href, + popupClassifications: PUSH_POPUP_CLASSIFICATIONS + }) + const now = Date.now() + const popupGuardDecision = getPopupGuardDecision({ + popupOpenKind, + lastPopupAt: popupOpenKind === 'in-page-push' ? null : getLastAdPopupAt(now), + now + }) + + if (!popupGuardDecision.shouldAllow) { + logAdPopupGuard(popupGuardDecision.event, { + requestedUrl, + ...popupGuardDecision.capLogDetails + }) + + throw createBlockedAdPopupError('frequency-cap') + } + + if (popupGuardDecision.shouldRecordPopupAt) { + recordAdPopupOpened(now) + } + + logAdPopupGuard(popupGuardDecision.event, { + requestedUrl, + ...(popupGuardDecision.cappedUntil ? { cappedUntil: popupGuardDecision.cappedUntil } : {}) + }) + + const popupHandle = originalWindowOpen(...args) + + if (popupGuardDecision.event === 'allow-popunder' && popupHandle === null) { + throw createBlockedAdPopupError('browser-blocked') + } + + return popupHandle + } + + isPopupGuardInstalled.value = true + } + + // Stop injecting ad scripts while the 20-minute popup cap is active. + if (isAdPopupCapActive()) { + logAdPopupGuard('skip-script-injection-while-capped', { + popunderScript: popunderScript.value || null, + pushScript: pushScript.value || null, + ...getAdPopupCapLogDetails() + }) + + return + } + + // Once scripts load, guard future popunder opens with the first-party cap. + isPopupGuardArmed.value = true + + const popunderAds = getProviderAds(POPUNDER_AD_PROVIDERS) + const pushAds = getProviderAds(PUSH_AD_PROVIDERS) + if (!popunderScript.value) { - const selectedPopunder = randomWeightedChoice(popunderAds) - popunderScript.value = selectedPopunder + popunderScript.value = randomWeightedChoice(popunderAds) } - // Load push notification ad if not already loaded if (!pushScript.value) { - const selectedPush = randomWeightedChoice(pushAds) - pushScript.value = selectedPush + pushScript.value = randomWeightedChoice(pushAds) } - // Load selected ads useHead({ script: [ { src: popunderScript.value, - async: false, - defer: true, + ...AD_SCRIPT_ATTRIBUTES, // Fix for CORS issues - https://unhead.unjs.io/usage/composables/use-script#referrerpolicy-and-crossorigin - crossorigin: 'anonymous' }, { src: pushScript.value, - async: false, - defer: true, - - crossorigin: 'anonymous' + ...AD_SCRIPT_ATTRIBUTES } ] }) } +export function openTrustedWindow(...args: WindowOpenArgs): WindowOpenResult { + if (!import.meta.client) { + return null + } + + const shouldBypassNextWindowOpenGuard = useState(AD_TRUSTED_WINDOW_OPEN_BYPASS_STATE_KEY, () => false) + + shouldBypassNextWindowOpenGuard.value = true + + try { + return window.open(...args) + } finally { + shouldBypassNextWindowOpenGuard.value = false + } +} + export function useChatWithAiReferral() { const chatWithAiReferrals = [ { diff --git a/pages/index.vue b/pages/index.vue index cfeb2801..34efe57c 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -92,7 +92,7 @@ description: 'You sent too many requests in a short period of time', action: { label: 'Verify I am not a Bot', - onClick: () => window.open(config.public.apiUrl + '/status', '_blank') + onClick: () => openTrustedWindow(config.public.apiUrl + '/status', '_blank') } }) break diff --git a/pages/posts/[domain].vue b/pages/posts/[domain].vue index bb842b9f..a7be5fb4 100644 --- a/pages/posts/[domain].vue +++ b/pages/posts/[domain].vue @@ -279,7 +279,7 @@ description: 'You sent too many requests in a short period of time', action: { label: 'Verify I am not a Bot', - onClick: () => window.open(config.public.apiUrl + '/status', '_blank') + onClick: () => openTrustedWindow(config.public.apiUrl + '/status', '_blank') } }) break @@ -355,7 +355,7 @@ const resolvedTagUrl = router.resolve(tagUrl).href - window.open(resolvedTagUrl, '_blank') + openTrustedWindow(resolvedTagUrl, '_blank') } async function onLoadNextPostPage() { diff --git a/pages/premium/dashboard.vue b/pages/premium/dashboard.vue index 8db169c2..c99380f2 100644 --- a/pages/premium/dashboard.vue +++ b/pages/premium/dashboard.vue @@ -82,7 +82,7 @@ return } - window.open(PLATFORM_URLS[platformOfPurchase.value], '_blank', 'noopener,noreferrer') + openTrustedWindow(PLATFORM_URLS[platformOfPurchase.value], '_blank', 'noopener,noreferrer') window._paq?.push(['trackEvent', 'Premium', 'Click "Manage subscription"', platformOfPurchase.value]) } diff --git a/test/pages/ads-popup-guard.browser.test.ts b/test/pages/ads-popup-guard.browser.test.ts new file mode 100644 index 00000000..c4ec82d4 --- /dev/null +++ b/test/pages/ads-popup-guard.browser.test.ts @@ -0,0 +1,279 @@ +import { createPage, setup, url } from '@nuxt/test-utils' +import { describe, expect, it } from 'vitest' +import type { Page } from 'playwright-core' +import { defaultBrowserOptions } from '../helper' + +const AD_LAST_POPUP_AT_STORAGE_KEY = 'ads-last-popup-at' +const AD_POPUP_CAP_DURATION_MS = 20 * 60 * 1000 +const FORCED_AD_RANDOM_VALUE = 0.9 +const INTERCEPTED_POPUNDER_URL = 'https://intercepted-ad.test/popunder' +const INTERCEPTED_FALLBACK_URL = 'https://intercepted-ad.test/fallback' + +const INTERCEPTED_DYNAMIC_POPUNDER_SCRIPT = ` +(() => { + if (window.__dynamicPopupAdInstalled) { + return + } + + window.__dynamicPopupAdInstalled = true + + document.addEventListener('click', () => { + const popup = window.open('${INTERCEPTED_POPUNDER_URL}', '_blank') + + if (!popup) { + window.location.href = '${INTERCEPTED_FALLBACK_URL}' + } + }, true) +})() +` + +type RequestCounts = { + popunderWrapper: number + popunderRemote: number + pushRemote: number + dynamicPopunderRemote: number + unexpectedPopunderRemote: number + remoteDocuments: number +} + +async function waitFor(predicate: () => boolean, timeoutMs = 10_000) { + const startedAt = Date.now() + + while (!predicate()) { + if (Date.now() - startedAt >= timeoutMs) { + throw new Error('Timed out waiting for expected browser-side condition') + } + + await new Promise(resolve => setTimeout(resolve, 50)) + } +} + +async function installRealAdRoutes(page: Page, appOrigin: string): Promise { + const requestCounts: RequestCounts = { + popunderWrapper: 0, + popunderRemote: 0, + pushRemote: 0, + dynamicPopunderRemote: 0, + unexpectedPopunderRemote: 0, + remoteDocuments: 0 + } + + await page.context().route('**/*', async (route) => { + const request = route.request() + const requestUrl = new URL(request.url()) + const isLocalDocument = request.resourceType() === 'document' && requestUrl.origin === appOrigin + const isLocalOrigin = requestUrl.origin === appOrigin + + if (request.resourceType() === 'document' && !isLocalDocument) { + requestCounts.remoteDocuments += 1 + + await route.fulfill({ + status: 200, + contentType: 'text/html', + body: 'Intercepted popuppopup' + }) + + return + } + + if (request.resourceType() === 'script' && !isLocalOrigin) { + requestCounts.dynamicPopunderRemote += 1 + + await route.fulfill({ + status: 200, + contentType: 'application/javascript', + body: INTERCEPTED_DYNAMIC_POPUNDER_SCRIPT + }) + + return + } + + await route.continue() + }) + + await page.route('**/js/popunder2.js*', async (route) => { + requestCounts.popunderWrapper += 1 + await route.continue() + }) + + await page.route('**bundlemoviepumice.com/on.js', async (route) => { + requestCounts.popunderRemote += 1 + + await route.fulfill({ + status: 200, + contentType: 'application/javascript', + body: 'window.__bundlemoviepumiceLoaded = true;' + }) + }) + + await page.route('**ellipticaltrack.com/**', async (route) => { + requestCounts.unexpectedPopunderRemote += 1 + + await route.fulfill({ + status: 200, + contentType: 'application/javascript', + body: '' + }) + }) + + await page.route('**udzpel.com/**', async (route) => { + requestCounts.pushRemote += 1 + + await route.fulfill({ + status: 200, + contentType: 'application/javascript', + body: 'window.__pushVendorLoaded = true;' + }) + }) + + await page.route('**hotbsizovu.today/**', async (route) => { + requestCounts.pushRemote += 1 + + await route.fulfill({ + status: 200, + contentType: 'application/javascript', + body: 'window.__pushVendorLoaded = true;' + }) + }) + + await page.route('**hotsoz.com/**', async (route) => { + requestCounts.pushRemote += 1 + + await route.fulfill({ + status: 200, + contentType: 'text/html', + body: 'Intercepted push popuppush' + }) + }) + + return requestCounts +} + +async function preparePage() { + const page = await createPage('/') + const appOrigin = new URL(page.url()).origin + const requestCounts = await installRealAdRoutes(page, appOrigin) + + await page.addInitScript(({ forcedRandomValue }) => { + Math.random = () => forcedRandomValue + }, { + forcedRandomValue: FORCED_AD_RANDOM_VALUE + }) + + await page.evaluate((storageKey) => { + window.localStorage.removeItem(storageKey) + }, AD_LAST_POPUP_AT_STORAGE_KEY) + + await page.reload() + await page.waitForSelector('h1') + + return { + page, + appOrigin, + requestCounts + } +} + +async function armAdvertisements(page: Page, requestCounts: RequestCounts, expectScriptInstall = true) { + await page.locator('h1').click() + + if (expectScriptInstall) { + await waitFor(() => { + return requestCounts.popunderWrapper > 0 + && requestCounts.popunderRemote > 0 + && requestCounts.dynamicPopunderRemote > 0 + }) + } +} + +describe('popup guard browser flow', async () => { + await setup({ + browser: true, + browserOptions: defaultBrowserOptions + }) + + it('uses the real popunder wrapper, blocks capped repeats, skips reinjection while capped, and allows again after expiry', async () => { + const { page, appOrigin, requestCounts } = await preparePage() + + await armAdvertisements(page, requestCounts) + + expect(requestCounts.popunderWrapper).toBeGreaterThan(0) + expect(requestCounts.popunderRemote).toBeGreaterThan(0) + expect(requestCounts.dynamicPopunderRemote).toBeGreaterThan(0) + expect(requestCounts.unexpectedPopunderRemote).toBe(0) + + const firstPopupPromise = page.waitForEvent('popup') + + await page.locator('h1').click() + + const firstPopup = await firstPopupPromise + await firstPopup.close() + + expect(await page.evaluate((storageKey) => { + return window.localStorage.getItem(storageKey) + }, AD_LAST_POPUP_AT_STORAGE_KEY)).toMatch(/^\d+$/) + expect(new URL(page.url()).origin).toBe(appOrigin) + + const popunderWrapperBeforeBlockedClick = requestCounts.popunderWrapper + const popunderRemoteBeforeBlockedClick = requestCounts.popunderRemote + const dynamicPopunderRemoteBeforeBlockedClick = requestCounts.dynamicPopunderRemote + const remoteDocumentsBeforeBlockedClick = requestCounts.remoteDocuments + const blockedPopupPromise = page.waitForEvent('popup', { timeout: 1_000 }).catch(() => null) + const blockedErrorPromise = page.waitForEvent('pageerror', { timeout: 1_000 }).catch(() => null) + + await page.locator('h1').click() + + const [blockedPopup, blockedError] = await Promise.all([blockedPopupPromise, blockedErrorPromise]) + + expect(blockedPopup === null).toBe(true) + expect(blockedError?.message).toContain('Ad popup blocked: frequency-cap') + expect(new URL(page.url()).origin).toBe(appOrigin) + expect(requestCounts.popunderWrapper).toBe(popunderWrapperBeforeBlockedClick) + expect(requestCounts.popunderRemote).toBe(popunderRemoteBeforeBlockedClick) + expect(requestCounts.dynamicPopunderRemote).toBe(dynamicPopunderRemoteBeforeBlockedClick) + expect(requestCounts.remoteDocuments).toBe(remoteDocumentsBeforeBlockedClick) + + await page.reload() + await page.waitForSelector('h1') + + const reloadRemoteDocumentsBeforeBlockedClick = requestCounts.remoteDocuments + const reloadBlockedPopupPromise = page.waitForEvent('popup', { timeout: 1_000 }).catch(() => null) + + await page.locator('h1').click() + + const reloadBlockedPopup = await reloadBlockedPopupPromise + + expect(reloadBlockedPopup === null).toBe(true) + expect(new URL(page.url()).origin).toBe(appOrigin) + expect(requestCounts.remoteDocuments).toBe(reloadRemoteDocumentsBeforeBlockedClick) + + await page.evaluate(({ storageKey, expiredAt }) => { + window.localStorage.setItem(storageKey, String(expiredAt)) + }, { + storageKey: AD_LAST_POPUP_AT_STORAGE_KEY, + expiredAt: Date.now() - AD_POPUP_CAP_DURATION_MS - 60_000 + }) + + await page.reload() + await page.waitForSelector('h1') + + const popunderWrapperRequestsBeforeExpiredInteraction = requestCounts.popunderWrapper + const popunderRemoteRequestsBeforeExpiredInteraction = requestCounts.popunderRemote + + await page.locator('h1').click() + await page.locator('h1').click() + + expect(requestCounts.popunderWrapper).toBeGreaterThan(popunderWrapperRequestsBeforeExpiredInteraction) + expect(requestCounts.popunderRemote).toBeGreaterThan(popunderRemoteRequestsBeforeExpiredInteraction) + + const expiredPopupPromise = page.waitForEvent('popup') + + await page.locator('h1').click() + + const expiredPopup = await expiredPopupPromise + await expiredPopup.close() + + expect(new URL(page.url()).origin).toBe(appOrigin) + expect(page.url()).not.toContain(INTERCEPTED_FALLBACK_URL) + }, 60_000) +}) diff --git a/test/pages/ads-popup-guard.test.ts b/test/pages/ads-popup-guard.test.ts new file mode 100644 index 00000000..24066f5d --- /dev/null +++ b/test/pages/ads-popup-guard.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest' +import { + AD_POPUP_CAP_DURATION_MS, + getAdPopupCapLogDetails, + getPopupGuardDecision, + getPopupOpenKind, + getTrustedPopupBypassDecision, + IN_PAGE_PUSH_SEARCH_PARAM_PREFIXES +} from '../../assets/js/ads-popup-guard' + +const REQUIRED_AD_POPUP_CAP_DURATION_MS = 20 * 60 * 1000 + +const PUSH_POPUP_CLASSIFICATIONS = [ + { + kind: 'in-page-push' as const, + hostnames: ['hotbsizovu.today', 'hotsoz.com'], + searchParamPrefixes: IN_PAGE_PUSH_SEARCH_PARAM_PREFIXES + }, + { + kind: 'in-page-push' as const, + hostnames: ['udzpel.com'], + searchParamPrefixes: IN_PAGE_PUSH_SEARCH_PARAM_PREFIXES + } +] + +describe('popup guard', () => { + it('keeps the popup cap at the required 20 minutes', () => { + expect(AD_POPUP_CAP_DURATION_MS).toBe(REQUIRED_AD_POPUP_CAP_DURATION_MS) + }) + + it('allows the first popunder attempt and records the cap decision path', () => { + const now = 1_710_000_000_000 + const popupOpenKind = getPopupOpenKind('https://bundlemoviepumice.com/test', { + baseUrl: 'https://r34.app/', + popupClassifications: PUSH_POPUP_CLASSIFICATIONS + }) + + const decision = getPopupGuardDecision({ + popupOpenKind, + lastPopupAt: null, + now + }) + + expect(decision).toEqual({ + event: 'allow-popunder', + shouldAllow: true, + shouldRecordPopupAt: true, + cappedUntil: now + REQUIRED_AD_POPUP_CAP_DURATION_MS + }) + }) + + it('blocks a second popunder attempt within the active cap window', () => { + const now = 1_710_000_000_000 + const fiveMinutesAgo = now - (5 * 60 * 1000) + + const decision = getPopupGuardDecision({ + popupOpenKind: 'popunder', + lastPopupAt: fiveMinutesAgo, + now + }) + + expect(decision.event).toBe('block-capped-popunder') + expect(decision.shouldAllow).toBe(false) + expect(decision.shouldRecordPopupAt).toBe(false) + expect(decision.capLogDetails).toEqual({ + lastPopupAt: fiveMinutesAgo, + cappedUntil: fiveMinutesAgo + REQUIRED_AD_POPUP_CAP_DURATION_MS, + remainingMs: REQUIRED_AD_POPUP_CAP_DURATION_MS - (5 * 60 * 1000) + }) + }) + + it('treats push provider URLs as in-page-push and exempts them from cap recording', () => { + const now = 1_710_000_000_000 + const popupOpenKind = getPopupOpenKind('https://hotsoz.com/wnclcm?inpage.campaign=foo', { + baseUrl: 'https://r34.app/', + popupClassifications: PUSH_POPUP_CLASSIFICATIONS + }) + + const decision = getPopupGuardDecision({ + popupOpenKind, + lastPopupAt: now - 60_000, + now + }) + + expect(popupOpenKind).toBe('in-page-push') + expect(decision).toEqual({ + event: 'allow-in-page-push', + shouldAllow: true, + shouldRecordPopupAt: false + }) + }) + + it('returns trusted bypass decision path for guarded window.open', () => { + expect(getTrustedPopupBypassDecision(true)).toEqual({ + shouldBypassCurrentOpen: true, + nextShouldBypass: false + }) + + expect(getTrustedPopupBypassDecision(false)).toEqual({ + shouldBypassCurrentOpen: false, + nextShouldBypass: false + }) + }) + + it('allows popunder after the active cap expires', () => { + const now = 1_710_000_000_000 + const expiredLastPopupAt = now - REQUIRED_AD_POPUP_CAP_DURATION_MS - 1 + + const decision = getPopupGuardDecision({ + popupOpenKind: 'popunder', + lastPopupAt: expiredLastPopupAt, + now + }) + + expect(decision).toEqual({ + event: 'allow-popunder', + shouldAllow: true, + shouldRecordPopupAt: true, + cappedUntil: now + REQUIRED_AD_POPUP_CAP_DURATION_MS + }) + + expect(getAdPopupCapLogDetails(expiredLastPopupAt, now)).toEqual({ + lastPopupAt: expiredLastPopupAt, + cappedUntil: expiredLastPopupAt + REQUIRED_AD_POPUP_CAP_DURATION_MS, + remainingMs: 0 + }) + }) + + it('classifies provider URL as in-page-push by hostname or param prefix', () => { + const byHostname = getPopupOpenKind('https://hotbsizovu.today/process.js?id=1', { + baseUrl: 'https://r34.app/', + popupClassifications: PUSH_POPUP_CLASSIFICATIONS + }) + const byQueryParamPrefix = getPopupOpenKind('https://unknown.example/path?inpage.foo=bar', { + baseUrl: 'https://r34.app/', + popupClassifications: PUSH_POPUP_CLASSIFICATIONS + }) + + expect(byHostname).toBe('in-page-push') + expect(byQueryParamPrefix).toBe('in-page-push') + }) +})