diff --git a/.env.example b/.env.example index fcbf6589..d3141cd1 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,4 @@ -PRIVATE_API_KEY= +SERVER_API_KEY= NEXT_PUBLIC_API_KEY= # "eu" or "ap", "us" is default -BACKEND_REGION= -# "eu" or "ap", "us" is default -NEXT_PUBLIC_FRONTEND_REGION= \ No newline at end of file +NEXT_PUBLIC_REGION= \ No newline at end of file diff --git a/README.md b/README.md index 87059342..3b573407 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,12 @@ Prevent financial losses from SMS pumping. Link every verification text message [📱 SMS Pumping Protection Live Demo](https://demo.fingerprint.com/sms-pumping) +### VPN Detection and Location Spoofing Protection + +Detect when visitors are using VPN to access your application. Prevent people spoofing their location from accessing geo-restricted content or pricing. + +[🌍 VPN Detection Live Demo](https://demo.fingerprint.com/vpn-detection) + ## Documentation and Support To dive deeper into Fingerprint Pro, see our [Documentation](https://dev.fingerprint.com/docs). For questions or suggestions specific to this repository, you can [create an issue](https://github.com/fingerprintjs/fingerprintjs-pro-use-cases/issues/new). For general questions and community vibes, visit our [Discord server](https://discord.gg/39EpE2neBg). If you require private support, you can email us at [oss-support@fingerprint.com](mailto:oss-support@fingerprint.com). diff --git a/e2e/coupon-fraud.spec.ts b/e2e/coupon-fraud.spec.ts index 5f000003..f881fa72 100644 --- a/e2e/coupon-fraud.spec.ts +++ b/e2e/coupon-fraud.spec.ts @@ -1,7 +1,7 @@ import { Page, test, expect } from '@playwright/test'; import { blockGoogleTagManager, resetScenarios } from './e2eTestUtils'; import { TEST_IDS } from '../src/client/testIDs'; -import { COUPON_FRAUD_COPY } from '../src/pages/api/coupon-fraud/claim'; +import { COUPON_FRAUD_COPY } from '../src/server/coupon-fraud/copy'; const insertCoupon = async (page: Page, coupon: string) => { await page.getByTestId(TEST_IDS.couponFraud.couponCode).fill(coupon); diff --git a/e2e/credential-stuffing.spec.ts b/e2e/credential-stuffing.spec.ts index 37dc0b1c..65875bd1 100644 --- a/e2e/credential-stuffing.spec.ts +++ b/e2e/credential-stuffing.spec.ts @@ -1,7 +1,7 @@ import { Page, test } from '@playwright/test'; import { blockGoogleTagManager, resetScenarios } from './e2eTestUtils'; import { TEST_IDS } from '../src/client/testIDs'; -import { CREDENTIAL_STUFFING_COPY } from '../src/pages/api/credential-stuffing/authenticate'; +import { CREDENTIAL_STUFFING_COPY } from '../src/server/credentialStuffing/copy'; const submitForm = async (page: Page) => { // Waits for the button to be clickable out of the box diff --git a/e2e/loan-risk.spec.ts b/e2e/loan-risk.spec.ts index 15fd736e..1ae91e8a 100644 --- a/e2e/loan-risk.spec.ts +++ b/e2e/loan-risk.spec.ts @@ -1,7 +1,7 @@ import { Page, expect, test } from '@playwright/test'; import { blockGoogleTagManager, resetScenarios } from './e2eTestUtils'; import { TEST_IDS } from '../src/client/testIDs'; -import { LOAN_RISK_COPY } from '../src/pages/api/loan-risk/request-loan'; +import { LOAN_RISK_COPY } from '../src/server/loan-risk/copy'; const testIds = TEST_IDS.loanRisk; diff --git a/e2e/payment-fraud.spec.ts b/e2e/payment-fraud.spec.ts index e1280354..4341681d 100644 --- a/e2e/payment-fraud.spec.ts +++ b/e2e/payment-fraud.spec.ts @@ -1,7 +1,7 @@ import { Page, test } from '@playwright/test'; import { blockGoogleTagManager, resetScenarios } from './e2eTestUtils'; -import { PAYMENT_FRAUD_COPY } from '../src/pages/api/payment-fraud/place-order'; import { TEST_IDS } from '../src/client/testIDs'; +import { PAYMENT_FRAUD_COPY } from '../src/server/paymentFraud/copy'; const submit = (page: Page) => page.getByTestId(TEST_IDS.paymentFraud.submitPayment).click(); diff --git a/e2e/playground.spec.ts b/e2e/playground.spec.ts index 16e401ae..ae38af4d 100644 --- a/e2e/playground.spec.ts +++ b/e2e/playground.spec.ts @@ -1,8 +1,6 @@ -import { SCRIPT_URL_PATTERN } from './../src/server/const'; import { Page, expect, test } from '@playwright/test'; import { PLAYGROUND_TAG } from '../src/client/components/playground/playgroundTags'; import { isAgentResponse, isServerResponse } from './zodUtils'; -import { ENDPOINT } from '../src/server/const'; import { blockGoogleTagManager } from './e2eTestUtils'; const getAgentResponse = async (page: Page) => { @@ -86,23 +84,29 @@ test.describe('Playground page', () => { }); test.describe('Proxy integration', () => { + const proxyIntegrations = [ + 'https://metrics.fingerprinthub.com', + 'https://demo.fingerprint.com/DBqbMN7zXxwl4Ei8', + process.env.NEXT_PUBLIC_ENDPOINT ?? 'NO_CUSTOM_PROXY_IN_ENV', + ]; + + /** + * If any JS agent network request fails, fail the test. + * This captures proxy integration failures that would otherwise go unnoticed thanks to default endpoint fallbacks. + */ test('Proxy integration works on Playground, no network errors', async ({ page }) => { - // If any JS agent network request fails, fails the test - // This captures proxy integration failures that would otherwise go unnoticed thanks to default endpoint fallbacks - const endpointOrigin = new URL(ENDPOINT).origin; - const scriptUrlPatternOrigin = new URL(SCRIPT_URL_PATTERN).origin; - page.on('requestfailed', (request) => { - console.error(request.url(), request.failure()?.errorText); - const requestOrigin = new URL(request.url()).origin; - - if (requestOrigin === endpointOrigin || requestOrigin === scriptUrlPatternOrigin) { - // This fails the test - expect(request.failure()).toBeUndefined(); - } + const url = request.url(); + const failure = request.failure()?.errorText; + + proxyIntegrations.forEach((proxy) => { + if (url.includes(proxy)) { + // This fails the test and prints the relevant info in test result + expect(url + ' ' + failure).toBeUndefined(); + } + }); }); - await page.goto('/playground'); await clickPlaygroundRefreshButton(page); }); }); diff --git a/e2e/sms-pumping/bot-unprotected.spec.ts b/e2e/sms-pumping/bot-unprotected.spec.ts index 477ab890..0d6e2180 100644 --- a/e2e/sms-pumping/bot-unprotected.spec.ts +++ b/e2e/sms-pumping/bot-unprotected.spec.ts @@ -4,11 +4,11 @@ import { MAX_SMS_ATTEMPTS, SMS_ATTEMPT_TIMEOUT_MAP, SMS_FRAUD_COPY, - TEST_BUILD, TEST_PHONE_NUMBER, } from '../../src/server/sms-pumping/smsPumpingConst'; import { assertAlert, assertSnackbar, blockGoogleTagManager, resetScenarios } from '../e2eTestUtils'; import { ONE_MINUTE_MS } from '../../src/shared/timeUtils'; +import { TEST_BUILD } from '../../src/envShared'; const TEST_ID = TEST_IDS.smsFraud; diff --git a/e2e/vpn-detection.spec.ts b/e2e/vpn-detection.spec.ts new file mode 100644 index 00000000..71bf0733 --- /dev/null +++ b/e2e/vpn-detection.spec.ts @@ -0,0 +1,43 @@ +import { test, expect, Page } from '@playwright/test'; +import { TEST_IDS } from '../src/client/testIDs'; +import { VPN_DETECTION_COPY } from '../src/app/vpn-detection/copy'; +import { assertAlert } from './e2eTestUtils'; + +const getActivateButton = (page: Page) => page.getByTestId(TEST_IDS.vpnDetection.activateRegionalPricing); + +test.describe('VPN Detection demo', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/vpn-detection'); + }); + + test('should personalize UI copy based on user location', async ({ page }) => { + await expect(page.getByTestId(TEST_IDS.vpnDetection.callout)).toContainText(VPN_DETECTION_COPY.personalizedCallout); + + const button = await page.getByTestId(TEST_IDS.vpnDetection.activateRegionalPricing); + await expect(button).toContainText(/\d+% off with/); + }); + + test('should allow to activate regional pricing without VPN', async ({ page }) => { + await getActivateButton(page).click(); + await assertAlert({ + page, + severity: 'success', + text: VPN_DETECTION_COPY.success({ discount: 20, country: 'Test Country' }).substring(0, 20), + }); + + const discountLineItem = await page.getByTestId(TEST_IDS.common.cart.discount); + await expect(discountLineItem).toContainText(VPN_DETECTION_COPY.discountName); + }); + + test('should not allow to activate regional pricing with VPN', async ({ page }) => { + // Mock positive VPN detection result + const vpnError = 'You are using a VPN.'; + await page.route('/vpn-detection/api/activate-ppp', (route) => + route.fulfill({ status: 403, body: JSON.stringify({ severity: 'error', message: vpnError }) }), + ); + + await getActivateButton(page).click(); + await assertAlert({ page, severity: 'error', text: vpnError }); + await expect(page.getByTestId(TEST_IDS.common.cart.discount)).toBeAttached({ attached: false }); + }); +}); diff --git a/next-env.d.ts b/next-env.d.ts index 4f11a03d..fd36f949 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +/// // NOTE: This file should not be edited // see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/package.json b/package.json index 74eca1de..cd355e93 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-slider": "^1.1.2", + "@t3-oss/env-nextjs": "^0.9.2", "classnames": "^2.5.1", "cors": "^2.8.5", "framer-motion": "^11.0.8", diff --git a/public/iphone14.png b/public/iphone14.png deleted file mode 100644 index 9b91e137..00000000 Binary files a/public/iphone14.png and /dev/null differ diff --git a/src/Layout.tsx b/src/Layout.tsx new file mode 100644 index 00000000..288baedb --- /dev/null +++ b/src/Layout.tsx @@ -0,0 +1,17 @@ +import './styles/global-styles.scss'; +import { FunctionComponent, PropsWithChildren } from 'react'; +import Footer from './client/components/common/Footer/Footer'; +import Header from './client/components/common/Header/Header'; +import styles from './styles/layout.module.scss'; +import DeploymentUtils from './client/DeploymentUtils'; + +export const Layout: FunctionComponent> = ({ children, embed }) => { + return ( +
+ {embed ? null :
} + +
{children}
+ {embed ? null :
} +
+ ); +}; diff --git a/src/Providers.tsx b/src/Providers.tsx new file mode 100644 index 00000000..dbcba834 --- /dev/null +++ b/src/Providers.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { QueryClient, QueryClientProvider } from 'react-query'; +import { SnackbarProvider } from 'notistack'; +import { PropsWithChildren } from 'react'; +import { CloseSnackbarButton, CustomSnackbar } from './client/components/common/Alert/Alert'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + }, + }, +}); + +function Providers({ children }: PropsWithChildren) { + return ( + + } + maxSnack={4} + autoHideDuration={5000} + anchorOrigin={{ + horizontal: 'left', + vertical: 'bottom', + }} + Components={{ + default: CustomSnackbar, + success: CustomSnackbar, + error: CustomSnackbar, + warning: CustomSnackbar, + info: CustomSnackbar, + }} + > + {children} + + + ); +} + +export default Providers; diff --git a/src/app/api/decrypt/route.ts b/src/app/api/decrypt/route.ts new file mode 100644 index 00000000..ca028bb9 --- /dev/null +++ b/src/app/api/decrypt/route.ts @@ -0,0 +1,19 @@ +import { EventResponse } from '@fingerprintjs/fingerprintjs-pro-server-api'; +import { decryptSealedResult } from '../../../server/decryptSealedResult'; + +export type DecryptPayload = { + sealedResult: string; +}; + +export type DecryptResponse = EventResponse; + +export async function POST(request: Request) { + try { + const sealedData = ((await request?.json()) as DecryptPayload).sealedResult; + const data = await decryptSealedResult(sealedData); + return Response.json(data); + } catch (e) { + console.error(e); + return Response.json({ error: e }, { status: 500, statusText: String(e) }); + } +} diff --git a/src/app/appLayout.tsx b/src/app/appLayout.tsx new file mode 100644 index 00000000..674f44e6 --- /dev/null +++ b/src/app/appLayout.tsx @@ -0,0 +1,9 @@ +'use client'; + +import { useSelectedLayoutSegments } from 'next/navigation'; +import { Layout } from '../Layout'; + +export default function LayoutUiInsideApp({ children }: { children: React.ReactNode }) { + const segments = useSelectedLayoutSegments(); + return {children}; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 00000000..498d2a20 --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,20 @@ +import Providers from '../Providers'; +import LayoutUiInsideApp from './appLayout'; + +export const metadata = { + title: 'Fingerprint Pro Use Cases', + description: + 'Explore the wide range of major use cases supported by Fingerprint, including a comprehensive demo that showcases both frontend and backend sample implementations with a persistent data layer for each use case.', +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + + {children} + + + + ); +} diff --git a/src/app/vpn-detection/VpnDetectionUseCase.tsx b/src/app/vpn-detection/VpnDetectionUseCase.tsx new file mode 100644 index 00000000..1ec2c600 --- /dev/null +++ b/src/app/vpn-detection/VpnDetectionUseCase.tsx @@ -0,0 +1,143 @@ +'use client'; + +import { UseCaseWrapper } from '../../client/components/common/UseCaseWrapper/UseCaseWrapper'; +import { FunctionComponent, useState } from 'react'; +import { USE_CASES } from '../../client/components/common/content'; +import styles from './vpnDetection.module.scss'; +import formStyles from '../../styles/forms.module.scss'; +import classNames from 'classnames'; +import { Alert } from '../../client/components/common/Alert/Alert'; +import Button from '../../client/components/common/Button/Button'; +import { Cart } from '../../client/components/common/Cart/Cart'; +import { FingerprintJSPro, FpjsProvider, useVisitorData } from '@fingerprintjs/fingerprintjs-pro-react'; +import { useMutation } from 'react-query'; +import { ActivateRegionalPricingPayload, ActivateRegionalPricingResponse } from './api/activate-ppp/route'; +import { useUnsealedResult } from '../../client/hooks/useUnsealedResult'; +import { getFlagEmoji, getIpLocation } from '../../shared/utils/locationUtils'; +import { getRegionalDiscount } from './data/getDiscountByCountry'; +import courseLogo from './fingerprintLogoLowOpacitySquareBordered.svg'; +import { env } from '../../env'; +import { TEST_IDS } from '../../client/testIDs'; +import { VPN_DETECTION_COPY } from './copy'; + +const COURSE_PRICE = 100; +const TAXES = 15; + +const VpnDetectionUseCase: FunctionComponent = () => { + const { getData: getVisitorData, data: visitorData } = useVisitorData({ + ignoreCache: true, + }); + const { data: unsealedVisitorData } = useUnsealedResult(visitorData?.sealedResult); + const visitorIpCountry = getIpLocation(unsealedVisitorData)?.country; + const potentialDiscount = getRegionalDiscount(visitorIpCountry?.code); + + const { + mutate: activateRegionalPricing, + isLoading, + data: activateResponse, + error: activateError, + } = useMutation({ + mutationKey: ['activate regional pricing'], + mutationFn: async () => { + const { requestId, sealedResult } = await getVisitorData({ ignoreCache: true }); + const response = await fetch('/vpn-detection/api/activate-ppp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ requestId, sealedResult } satisfies ActivateRegionalPricingPayload), + }); + return await response.json(); + }, + onSuccess: (data: ActivateRegionalPricingResponse) => { + if (data.severity === 'success') { + setDiscount(data.data.discount); + } else { + setDiscount(0); + } + }, + }); + + const [courseCount, setCourseCount] = useState(1); + const [discount, setDiscount] = useState(0); + + const cartItems = [ + { + id: 0, + name: 'Fight Online Fraud Course', + subheadline: 'Fingerprint Inc.', + price: COURSE_PRICE, + image: courseLogo, + count: courseCount, + increaseCount: () => setCourseCount(courseCount + 1), + decreaseCount: () => setCourseCount(Math.max(1, courseCount - 1)), + }, + ]; + + return ( +
+ +
+
{ + e.preventDefault(); + activateRegionalPricing(); + }} + className={classNames(formStyles.useCaseForm, styles.regionalPricingForm)} + > +

+ {visitorIpCountry && ( + <> + {VPN_DETECTION_COPY.personalizedCallout} {getFlagEmoji(visitorIpCountry.code)} {visitorIpCountry.name}! + 👋{' '} + + )} + We are offering purchasing power parity pricing. +

+ {Boolean(activateError) && {String(activateError)}} + {activateResponse?.message && !isLoading && ( +
+ {activateResponse.message} +
+ )} +
+ +
+
+
+ +
+ ); +}; + +export const VpnDetectionUseCaseWrapped: FunctionComponent = () => { + return ( + + + + + + ); +}; diff --git a/src/app/vpn-detection/api/activate-ppp/route.ts b/src/app/vpn-detection/api/activate-ppp/route.ts new file mode 100644 index 00000000..4a9d22ec --- /dev/null +++ b/src/app/vpn-detection/api/activate-ppp/route.ts @@ -0,0 +1,75 @@ +import { NextResponse } from 'next/server'; +import { getAndValidateFingerprintResult } from '../../../../server/checks'; +import { getRegionalDiscount } from '../../data/getDiscountByCountry'; +import { env } from '../../../../env'; +import { VPN_DETECTION_COPY } from '../../copy'; +import { getIpLocation, getLocationName } from '../../../../shared/utils/locationUtils'; + +export type ActivateRegionalPricingPayload = { + requestId: string; + sealedResult?: string; +}; + +export type ActivateRegionalPricingResponse = + | { severity: 'success'; message: string; data: { discount: number } } + | { + severity: 'warning' | 'error'; + message: string; + }; + +export async function POST(req: Request): Promise> { + const { requestId, sealedResult } = (await req.json()) as ActivateRegionalPricingPayload; + + // Get the full Identification result from Fingerprint Server API and validate its authenticity + const fingerprintResult = await getAndValidateFingerprintResult({ + requestId, + req, + sealedResult, + serverApiKey: env.SEALED_RESULTS_SERVER_API_KEY, + }); + if (!fingerprintResult.okay) { + return NextResponse.json({ severity: 'error', message: fingerprintResult.error }, { status: 403 }); + } + + const location = getIpLocation(fingerprintResult.data); + const locationName = getLocationName(location, false); + const vpnDetection = fingerprintResult.data.products?.vpn?.data; + + if (!location?.country) { + return NextResponse.json( + { + severity: 'error', + message: 'Location could not be determined, please try a different browser or internet connection.', + }, + { status: 403 }, + ); + } + + if (vpnDetection?.result === true) { + let reason = ''; + if (vpnDetection.methods?.publicVPN) { + reason = `Your IP address appears to be in ${locationName} but it's a known VPN IP address.`; + } + if (vpnDetection.methods?.timezoneMismatch && vpnDetection.originTimezone) { + reason = `Your IP address appears to be in ${locationName}, but your timezone is ${vpnDetection.originTimezone}.`; + } + if (vpnDetection.methods?.auxiliaryMobile) { + reason = `Your IP address appears to be in ${locationName}, but your phone is not.`; + } + return NextResponse.json( + { + severity: 'error', + message: `It seems you are using a VPN. Please turn it off and use a regular local internet connection before activating regional pricing. ${reason}`, + }, + { status: 403 }, + ); + } + + const discount = getRegionalDiscount(location.country.code); + + return NextResponse.json({ + severity: 'success', + message: VPN_DETECTION_COPY.success({ discount, country: location.country.name }), + data: { discount }, + }); +} diff --git a/src/app/vpn-detection/copy.ts b/src/app/vpn-detection/copy.ts new file mode 100644 index 00000000..66a71f6a --- /dev/null +++ b/src/app/vpn-detection/copy.ts @@ -0,0 +1,6 @@ +export const VPN_DETECTION_COPY = { + success: ({ discount, country }: { discount: number; country: string }) => + `Success! We have applied a regional discount of ${discount}%. With this discount your purchase will be restricted to ${country}.`, + discountName: 'Regional discount', + personalizedCallout: 'We noticed you are from', +} as const; diff --git a/src/app/vpn-detection/data/build-ppp-table.ts b/src/app/vpn-detection/data/build-ppp-table.ts new file mode 100644 index 00000000..ef01bea0 --- /dev/null +++ b/src/app/vpn-detection/data/build-ppp-table.ts @@ -0,0 +1,34 @@ +// Source https://data.worldbank.org/indicator/NY.GDP.PCAP.PP.CD?end=2022 +// Surprisingly I couldn't find any publically available industry-standard PPP data +// Obviously GDP is just an imperfect heuristic for purchasing power, but good enough for our purposes here +import gdpData from './gdp-capita-by-country.json'; +import fs from 'fs'; + +// Use USA GDP as a reference point +const usaGdp = gdpData.find((country) => country.countryCode === 'US')?.gdpPerCapita as number; +if (!usaGdp) { + throw new Error('USA GDP not found'); +} + +const MIN_PPP = 0.3; + +const pppMap: Record = {}; + +gdpData.forEach((country) => { + // Countries with no GDP data get a placeholder PPP of 80% + if (country.gdpPerCapita === null) { + pppMap[country.countryCode] = 0.8; + } + // Even rich countries get a small discount for demo purposes + else if (country.gdpPerCapita >= usaGdp) { + pppMap[country.countryCode] = 0.95; + } + // Other countries get a PPP proportional to their GDP, but min 30% + else { + const ppp = Math.max(country.gdpPerCapita / usaGdp, MIN_PPP); + pppMap[country.countryCode] = Math.round(ppp * 100) / 100; + } +}); + +console.log(pppMap); +fs.writeFileSync('./src/app/vpn-detection/data/ppp-by-country.json', JSON.stringify(pppMap)); diff --git a/src/app/vpn-detection/data/gdp-capita-by-country.json b/src/app/vpn-detection/data/gdp-capita-by-country.json new file mode 100644 index 00000000..abf126ad --- /dev/null +++ b/src/app/vpn-detection/data/gdp-capita-by-country.json @@ -0,0 +1,218 @@ +[ + { "countryCode": "AW", "countryName": "Aruba", "gdpPerCapita": 48750.31601 }, + { "countryCode": "AF", "countryName": "Afghanistan", "gdpPerCapita": null }, + { "countryCode": "AO", "countryName": "Angola", "gdpPerCapita": 6976.006469 }, + { "countryCode": "AL", "countryName": "Albania", "gdpPerCapita": 19496.21485 }, + { "countryCode": "AD", "countryName": "Andorra", "gdpPerCapita": null }, + { "countryCode": "AE", "countryName": "United Arab Emirates", "gdpPerCapita": 88488.98461 }, + { "countryCode": "AR", "countryName": "Argentina", "gdpPerCapita": 26530.32312 }, + { "countryCode": "AM", "countryName": "Armenia", "gdpPerCapita": 18965.72757 }, + { "countryCode": "AS", "countryName": "American Samoa", "gdpPerCapita": null }, + { "countryCode": "AG", "countryName": "Antigua and Barbuda", "gdpPerCapita": 26365.46898 }, + { "countryCode": "AU", "countryName": "Australia", "gdpPerCapita": 65388.21596 }, + { "countryCode": "AT", "countryName": "Austria", "gdpPerCapita": 70975.72063 }, + { "countryCode": "AZ", "countryName": "Azerbaijan", "gdpPerCapita": 17828.60839 }, + { "countryCode": "BI", "countryName": "Burundi", "gdpPerCapita": 836.4645675 }, + { "countryCode": "BE", "countryName": "Belgium", "gdpPerCapita": 68253.33207 }, + { "countryCode": "BJ", "countryName": "Benin", "gdpPerCapita": 4057.451755 }, + { "countryCode": "BF", "countryName": "Burkina Faso", "gdpPerCapita": 2549.934982 }, + { "countryCode": "BD", "countryName": "Bangladesh", "gdpPerCapita": 7397.545737 }, + { "countryCode": "BG", "countryName": "Bulgaria", "gdpPerCapita": 35469.87165 }, + { "countryCode": "BH", "countryName": "Bahrain", "gdpPerCapita": 61248.18013 }, + { "countryCode": "BS", "countryName": "Bahamas, The", "gdpPerCapita": 40942.77672 }, + { "countryCode": "BA", "countryName": "Bosnia and Herzegovina", "gdpPerCapita": 20950.19985 }, + { "countryCode": "BY", "countryName": "Belarus", "gdpPerCapita": 22550.64044 }, + { "countryCode": "BZ", "countryName": "Belize", "gdpPerCapita": 11189.86408 }, + { "countryCode": "BM", "countryName": "Bermuda", "gdpPerCapita": 95868.78026 }, + { "countryCode": "BO", "countryName": "Bolivia", "gdpPerCapita": 9737.676068 }, + { "countryCode": "BR", "countryName": "Brazil", "gdpPerCapita": 17827.64105 }, + { "countryCode": "BB", "countryName": "Barbados", "gdpPerCapita": 18210.1343 }, + { "countryCode": "BN", "countryName": "Brunei Darussalam", "gdpPerCapita": 69297.93271 }, + { "countryCode": "BT", "countryName": "Bhutan", "gdpPerCapita": null }, + { "countryCode": "BW", "countryName": "Botswana", "gdpPerCapita": 18329.84926 }, + { "countryCode": "CF", "countryName": "Central African Republic", "gdpPerCapita": 973.2462704 }, + { "countryCode": "CA", "countryName": "Canada", "gdpPerCapita": 61380.19839 }, + { "countryCode": "CH", "countryName": "Switzerland", "gdpPerCapita": 90746.45328 }, + { "countryCode": "CL", "countryName": "Chile", "gdpPerCapita": 31436.57725 }, + { "countryCode": "CN", "countryName": "China", "gdpPerCapita": 21482.56218 }, + { "countryCode": "CI", "countryName": "Cote d'Ivoire", "gdpPerCapita": 6540.462355 }, + { "countryCode": "CM", "countryName": "Cameroon", "gdpPerCapita": 4398.048919 }, + { "countryCode": "CD", "countryName": "Congo, Dem. Rep.", "gdpPerCapita": 1337.834149 }, + { "countryCode": "CG", "countryName": "Congo, Rep.", "gdpPerCapita": 4335.055001 }, + { "countryCode": "CO", "countryName": "Colombia", "gdpPerCapita": 20951.54073 }, + { "countryCode": "KM", "countryName": "Comoros", "gdpPerCapita": 3833.738563 }, + { "countryCode": "CV", "countryName": "Cabo Verde", "gdpPerCapita": 8715.995684 }, + { "countryCode": "CR", "countryName": "Costa Rica", "gdpPerCapita": 26181.13829 }, + { "countryCode": "CU", "countryName": "Cuba", "gdpPerCapita": null }, + { "countryCode": "CW", "countryName": "Curacao", "gdpPerCapita": 27302.21764 }, + { "countryCode": "KY", "countryName": "Cayman Islands", "gdpPerCapita": 84279.61998 }, + { "countryCode": "CY", "countryName": "Cyprus", "gdpPerCapita": 53785.74609 }, + { "countryCode": "CZ", "countryName": "Czechia", "gdpPerCapita": 51695.18166 }, + { "countryCode": "DE", "countryName": "Germany", "gdpPerCapita": 66616.02225 }, + { "countryCode": "DJ", "countryName": "Djibouti", "gdpPerCapita": 5893.238371 }, + { "countryCode": "DM", "countryName": "Dominica", "gdpPerCapita": 13540.22461 }, + { "countryCode": "DK", "countryName": "Denmark", "gdpPerCapita": 77953.67866 }, + { "countryCode": "DO", "countryName": "Dominican Republic", "gdpPerCapita": 22841.0898 }, + { "countryCode": "DZ", "countryName": "Algeria", "gdpPerCapita": 13226.78957 }, + { "countryCode": "EC", "countryName": "Ecuador", "gdpPerCapita": 12826.36135 }, + { "countryCode": "EG", "countryName": "Egypt, Arab Rep.", "gdpPerCapita": 15095.99005 }, + { "countryCode": "ER", "countryName": "Eritrea", "gdpPerCapita": null }, + { "countryCode": "ES", "countryName": "Spain", "gdpPerCapita": 48685.49631 }, + { "countryCode": "EE", "countryName": "Estonia", "gdpPerCapita": 48168.3971 }, + { "countryCode": "ET", "countryName": "Ethiopia", "gdpPerCapita": 2812.513134 }, + { "countryCode": "FI", "countryName": "Finland", "gdpPerCapita": 62823.03529 }, + { "countryCode": "FJ", "countryName": "Fiji", "gdpPerCapita": 14632.21006 }, + { "countryCode": "FR", "countryName": "France", "gdpPerCapita": 57594.03402 }, + { "countryCode": "FO", "countryName": "Faroe Islands", "gdpPerCapita": null }, + { "countryCode": "FM", "countryName": "Micronesia, Fed. Sts.", "gdpPerCapita": 3854.877001 }, + { "countryCode": "GA", "countryName": "Gabon", "gdpPerCapita": 16464.97432 }, + { "countryCode": "GB", "countryName": "United Kingdom", "gdpPerCapita": 57460.50575 }, + { "countryCode": "GE", "countryName": "Georgia", "gdpPerCapita": 20172.07646 }, + { "countryCode": "GH", "countryName": "Ghana", "gdpPerCapita": 6473.089846 }, + { "countryCode": "GI", "countryName": "Gibraltar", "gdpPerCapita": null }, + { "countryCode": "GN", "countryName": "Guinea", "gdpPerCapita": 3188.075104 }, + { "countryCode": "GM", "countryName": "Gambia, The", "gdpPerCapita": 2496.495435 }, + { "countryCode": "GW", "countryName": "Guinea-Bissau", "gdpPerCapita": 2191.164859 }, + { "countryCode": "GQ", "countryName": "Equatorial Guinea", "gdpPerCapita": 17620.74097 }, + { "countryCode": "GR", "countryName": "Greece", "gdpPerCapita": 38922.47423 }, + { "countryCode": "GD", "countryName": "Grenada", "gdpPerCapita": 17082.89267 }, + { "countryCode": "GL", "countryName": "Greenland", "gdpPerCapita": null }, + { "countryCode": "GT", "countryName": "Guatemala", "gdpPerCapita": 10821.75545 }, + { "countryCode": "GU", "countryName": "Guam", "gdpPerCapita": null }, + { "countryCode": "GY", "countryName": "Guyana", "gdpPerCapita": 42089.90036 }, + { "countryCode": "HK", "countryName": "Hong Kong SAR, China", "gdpPerCapita": 69072.31048 }, + { "countryCode": "HN", "countryName": "Honduras", "gdpPerCapita": 6743.330064 }, + { "countryCode": "HR", "countryName": "Croatia", "gdpPerCapita": 42171.1848 }, + { "countryCode": "HT", "countryName": "Haiti", "gdpPerCapita": 3306.170824 }, + { "countryCode": "HU", "countryName": "Hungary", "gdpPerCapita": 43659.45077 }, + { "countryCode": "ID", "countryName": "Indonesia", "gdpPerCapita": 14657.78234 }, + { "countryCode": "IM", "countryName": "Isle of Man", "gdpPerCapita": null }, + { "countryCode": "IN", "countryName": "India", "gdpPerCapita": 8400.382848 }, + { "countryCode": "IE", "countryName": "Ireland", "gdpPerCapita": 133822.7587 }, + { "countryCode": "IR", "countryName": "Iran, Islamic Rep.", "gdpPerCapita": 18261.84846 }, + { "countryCode": "IQ", "countryName": "Iraq", "gdpPerCapita": 10865.42008 }, + { "countryCode": "IS", "countryName": "Iceland", "gdpPerCapita": 71840.12649 }, + { "countryCode": "IL", "countryName": "Israel", "gdpPerCapita": 52133.60932 }, + { "countryCode": "IT", "countryName": "Italy", "gdpPerCapita": 55442.07843 }, + { "countryCode": "JM", "countryName": "Jamaica", "gdpPerCapita": 11938.90097 }, + { "countryCode": "JO", "countryName": "Jordan", "gdpPerCapita": 11209.89768 }, + { "countryCode": "JP", "countryName": "Japan", "gdpPerCapita": 46850.08439 }, + { "countryCode": "KZ", "countryName": "Kazakhstan", "gdpPerCapita": 30820.08578 }, + { "countryCode": "KE", "countryName": "Kenya", "gdpPerCapita": 5765.819497 }, + { "countryCode": "KG", "countryName": "Kyrgyz Republic", "gdpPerCapita": 5988.60618 }, + { "countryCode": "KH", "countryName": "Cambodia", "gdpPerCapita": 5355.171908 }, + { "countryCode": "KI", "countryName": "Kiribati", "gdpPerCapita": 2365.509923 }, + { "countryCode": "KN", "countryName": "St. Kitts and Nevis", "gdpPerCapita": 33981.8396 }, + { "countryCode": "KR", "countryName": "Korea, Rep.", "gdpPerCapita": 51666.37504 }, + { "countryCode": "KW", "countryName": "Kuwait", "gdpPerCapita": 58349.21141 }, + { "countryCode": "LA", "countryName": "Lao PDR", "gdpPerCapita": 9387.374412 }, + { "countryCode": "LB", "countryName": "Lebanon", "gdpPerCapita": null }, + { "countryCode": "LR", "countryName": "Liberia", "gdpPerCapita": null }, + { "countryCode": "LY", "countryName": "Libya", "gdpPerCapita": 23382.73241 }, + { "countryCode": "LC", "countryName": "St. Lucia", "gdpPerCapita": 17835.69754 }, + { "countryCode": "LI", "countryName": "Liechtenstein", "gdpPerCapita": null }, + { "countryCode": "LK", "countryName": "Sri Lanka", "gdpPerCapita": 14410.18802 }, + { "countryCode": "LS", "countryName": "Lesotho", "gdpPerCapita": 2646.175924 }, + { "countryCode": "LT", "countryName": "Lithuania", "gdpPerCapita": 50968.93939 }, + { "countryCode": "LU", "countryName": "Luxembourg", "gdpPerCapita": 146457.0205 }, + { "countryCode": "LV", "countryName": "Latvia", "gdpPerCapita": 41624.65676 }, + { "countryCode": "MO", "countryName": "Macao SAR, China", "gdpPerCapita": 61230.96247 }, + { "countryCode": "MF", "countryName": "St. Martin (French part)", "gdpPerCapita": null }, + { "countryCode": "MA", "countryName": "Morocco", "gdpPerCapita": 9547.664063 }, + { "countryCode": "MC", "countryName": "Monaco", "gdpPerCapita": null }, + { "countryCode": "MD", "countryName": "Moldova", "gdpPerCapita": 15719.23249 }, + { "countryCode": "MG", "countryName": "Madagascar", "gdpPerCapita": 1774.656304 }, + { "countryCode": "MV", "countryName": "Maldives", "gdpPerCapita": 25124.60945 }, + { "countryCode": "MX", "countryName": "Mexico", "gdpPerCapita": 23900.37992 }, + { "countryCode": "MH", "countryName": "Marshall Islands", "gdpPerCapita": 7092.027364 }, + { "countryCode": "MK", "countryName": "North Macedonia", "gdpPerCapita": 21304.46201 }, + { "countryCode": "ML", "countryName": "Mali", "gdpPerCapita": 2518.947283 }, + { "countryCode": "MT", "countryName": "Malta", "gdpPerCapita": 58546.92442 }, + { "countryCode": "MM", "countryName": "Myanmar", "gdpPerCapita": 5019.739732 }, + { "countryCode": "ME", "countryName": "Montenegro", "gdpPerCapita": 28324.56869 }, + { "countryCode": "MN", "countryName": "Mongolia", "gdpPerCapita": 14260.30965 }, + { "countryCode": "MP", "countryName": "Northern Mariana Islands", "gdpPerCapita": null }, + { "countryCode": "MZ", "countryName": "Mozambique", "gdpPerCapita": 1477.3494 }, + { "countryCode": "MR", "countryName": "Mauritania", "gdpPerCapita": 6295.780998 }, + { "countryCode": "MU", "countryName": "Mauritius", "gdpPerCapita": 26979.05322 }, + { "countryCode": "MW", "countryName": "Malawi", "gdpPerCapita": 1732.604111 }, + { "countryCode": "MY", "countryName": "Malaysia", "gdpPerCapita": 33525.30125 }, + { "countryCode": "NA", "countryName": "Namibia", "gdpPerCapita": 11531.30052 }, + { "countryCode": "NC", "countryName": "New Caledonia", "gdpPerCapita": null }, + { "countryCode": "NE", "countryName": "Niger", "gdpPerCapita": 1505.542648 }, + { "countryCode": "NG", "countryName": "Nigeria", "gdpPerCapita": 5862.23507 }, + { "countryCode": "NI", "countryName": "Nicaragua", "gdpPerCapita": 6877.070832 }, + { "countryCode": "NL", "countryName": "Netherlands", "gdpPerCapita": 74541.79924 }, + { "countryCode": "NO", "countryName": "Norway", "gdpPerCapita": 121259.24 }, + { "countryCode": "NP", "countryName": "Nepal", "gdpPerCapita": 4726.606504 }, + { "countryCode": "NR", "countryName": "Nauru", "gdpPerCapita": 13021.39887 }, + { "countryCode": "NZ", "countryName": "New Zealand", "gdpPerCapita": 52547.01177 }, + { "countryCode": "OM", "countryName": "Oman", "gdpPerCapita": 41738.16078 }, + { "countryCode": "PK", "countryName": "Pakistan", "gdpPerCapita": 6351.002523 }, + { "countryCode": "PA", "countryName": "Panama", "gdpPerCapita": 39292.68751 }, + { "countryCode": "PE", "countryName": "Peru", "gdpPerCapita": 15052.5031 }, + { "countryCode": "PH", "countryName": "Philippines", "gdpPerCapita": 10136.55271 }, + { "countryCode": "PW", "countryName": "Palau", "gdpPerCapita": null }, + { "countryCode": "PG", "countryName": "Papua New Guinea", "gdpPerCapita": 4432.789618 }, + { "countryCode": "PL", "countryName": "Poland", "gdpPerCapita": 46609.60626 }, + { "countryCode": "PR", "countryName": "Puerto Rico", "gdpPerCapita": 40510.99243 }, + { "countryCode": "KP", "countryName": "Korea, Dem. People's Rep.", "gdpPerCapita": null }, + { "countryCode": "PT", "countryName": "Portugal", "gdpPerCapita": 44484.29744 }, + { "countryCode": "PY", "countryName": "Paraguay", "gdpPerCapita": 15982.60444 }, + { "countryCode": "PS", "countryName": "West Bank and Gaza", "gdpPerCapita": 6759.021598 }, + { "countryCode": "PF", "countryName": "French Polynesia", "gdpPerCapita": null }, + { "countryCode": "QA", "countryName": "Qatar", "gdpPerCapita": 114049.228 }, + { "countryCode": "RO", "countryName": "Romania", "gdpPerCapita": 43239.64299 }, + { "countryCode": "RU", "countryName": "Russian Federation", "gdpPerCapita": 34637.76172 }, + { "countryCode": "RW", "countryName": "Rwanda", "gdpPerCapita": 2793.185021 }, + { "countryCode": "SA", "countryName": "Saudi Arabia", "gdpPerCapita": 59279.89005 }, + { "countryCode": "SD", "countryName": "Sudan", "gdpPerCapita": 4217.421875 }, + { "countryCode": "SN", "countryName": "Senegal", "gdpPerCapita": 4210.359279 }, + { "countryCode": "SG", "countryName": "Singapore", "gdpPerCapita": 127606.8148 }, + { "countryCode": "SB", "countryName": "Solomon Islands", "gdpPerCapita": 2654.970328 }, + { "countryCode": "SL", "countryName": "Sierra Leone", "gdpPerCapita": 1930.900727 }, + { "countryCode": "SV", "countryName": "El Salvador", "gdpPerCapita": 11098.09839 }, + { "countryCode": "SM", "countryName": "San Marino", "gdpPerCapita": null }, + { "countryCode": "SO", "countryName": "Somalia", "gdpPerCapita": 1710.962281 }, + { "countryCode": "RS", "countryName": "Serbia", "gdpPerCapita": 25061.84798 }, + { "countryCode": "SS", "countryName": "South Sudan", "gdpPerCapita": null }, + { "countryCode": "ST", "countryName": "Sao Tome and Principe", "gdpPerCapita": 4061.808081 }, + { "countryCode": "SR", "countryName": "Suriname", "gdpPerCapita": 17773.48396 }, + { "countryCode": "SK", "countryName": "Slovak Republic", "gdpPerCapita": 41013.38981 }, + { "countryCode": "SI", "countryName": "Slovenia", "gdpPerCapita": 51281.85751 }, + { "countryCode": "SE", "countryName": "Sweden", "gdpPerCapita": 68178.03037 }, + { "countryCode": "SZ", "countryName": "Eswatini", "gdpPerCapita": 10699.54236 }, + { "countryCode": "SX", "countryName": "Sint Maarten (Dutch part)", "gdpPerCapita": 49540.86453 }, + { "countryCode": "SC", "countryName": "Seychelles", "gdpPerCapita": 29771.74661 }, + { "countryCode": "SY", "countryName": "Syrian Arab Republic", "gdpPerCapita": null }, + { "countryCode": "TC", "countryName": "Turks and Caicos Islands", "gdpPerCapita": 24471.51787 }, + { "countryCode": "TD", "countryName": "Chad", "gdpPerCapita": 1668.575526 }, + { "countryCode": "TG", "countryName": "Togo", "gdpPerCapita": 2601.750009 }, + { "countryCode": "TH", "countryName": "Thailand", "gdpPerCapita": 20679.11948 }, + { "countryCode": "TJ", "countryName": "Tajikistan", "gdpPerCapita": 4886.743992 }, + { "countryCode": "TM", "countryName": "Turkmenistan", "gdpPerCapita": null }, + { "countryCode": "TL", "countryName": "Timor-Leste", "gdpPerCapita": 4657.382251 }, + { "countryCode": "TO", "countryName": "Tonga", "gdpPerCapita": null }, + { "countryCode": "TT", "countryName": "Trinidad and Tobago", "gdpPerCapita": 27515.52642 }, + { "countryCode": "TN", "countryName": "Tunisia", "gdpPerCapita": 12483.63069 }, + { "countryCode": "TR", "countryName": "Turkiye", "gdpPerCapita": 38355.15397 }, + { "countryCode": "TV", "countryName": "Tuvalu", "gdpPerCapita": 5423.048668 }, + { "countryCode": "TZ", "countryName": "Tanzania", "gdpPerCapita": 3099.17334 }, + { "countryCode": "UG", "countryName": "Uganda", "gdpPerCapita": 2693.108305 }, + { "countryCode": "UA", "countryName": "Ukraine", "gdpPerCapita": 12675.43652 }, + { "countryCode": "UY", "countryName": "Uruguay", "gdpPerCapita": 28851.54016 }, + { "countryCode": "US", "countryName": "United States", "gdpPerCapita": 76329.58227 }, + { "countryCode": "UZ", "countryName": "Uzbekistan", "gdpPerCapita": 9535.669691 }, + { "countryCode": "VC", "countryName": "St. Vincent and the Grenadines", "gdpPerCapita": 17212.4297 }, + { "countryCode": "VE", "countryName": "Venezuela, RB", "gdpPerCapita": null }, + { "countryCode": "VG", "countryName": "British Virgin Islands", "gdpPerCapita": null }, + { "countryCode": "VI", "countryName": "Virgin Islands (U.S.)", "gdpPerCapita": null }, + { "countryCode": "VN", "countryName": "Viet Nam", "gdpPerCapita": 13461.00897 }, + { "countryCode": "VU", "countryName": "Vanuatu", "gdpPerCapita": 3290.569371 }, + { "countryCode": "WS", "countryName": "Samoa", "gdpPerCapita": 6089.558852 }, + { "countryCode": "XK", "countryName": "Kosovo", "gdpPerCapita": 14971.15206 }, + { "countryCode": "YE", "countryName": "Yemen, Rep.", "gdpPerCapita": null }, + { "countryCode": "ZA", "countryName": "South Africa", "gdpPerCapita": 15920.42541 }, + { "countryCode": "ZM", "countryName": "Zambia", "gdpPerCapita": 3975.600639 }, + { "countryCode": "ZW", "countryName": "Zimbabwe", "gdpPerCapita": 2607.927678 } +] diff --git a/src/app/vpn-detection/data/getDiscountByCountry.ts b/src/app/vpn-detection/data/getDiscountByCountry.ts new file mode 100644 index 00000000..474cd0a5 --- /dev/null +++ b/src/app/vpn-detection/data/getDiscountByCountry.ts @@ -0,0 +1,16 @@ +import pppByCountry from './ppp-by-country.json'; + +export const roundToPlaces = (num: number, places: number) => { + const factor = Math.pow(10, places); + return Math.round(num * factor) / factor; +}; + +const FALLBACK_DISCOUNT = 20; + +export const getRegionalDiscount = (countryCode?: string) => { + if (!countryCode) { + return FALLBACK_DISCOUNT; + } + const ppp = (pppByCountry as Record)[countryCode] ?? 0.8; + return roundToPlaces((1 - ppp) * 100, 2); +}; diff --git a/src/app/vpn-detection/data/ppp-by-country.json b/src/app/vpn-detection/data/ppp-by-country.json new file mode 100644 index 00000000..e9b8854a --- /dev/null +++ b/src/app/vpn-detection/data/ppp-by-country.json @@ -0,0 +1,218 @@ +{ + "AW": 0.64, + "AF": 0.8, + "AO": 0.3, + "AL": 0.3, + "AD": 0.8, + "AE": 0.95, + "AR": 0.35, + "AM": 0.3, + "AS": 0.8, + "AG": 0.35, + "AU": 0.86, + "AT": 0.93, + "AZ": 0.3, + "BI": 0.3, + "BE": 0.89, + "BJ": 0.3, + "BF": 0.3, + "BD": 0.3, + "BG": 0.46, + "BH": 0.8, + "BS": 0.54, + "BA": 0.3, + "BY": 0.3, + "BZ": 0.3, + "BM": 0.95, + "BO": 0.3, + "BR": 0.3, + "BB": 0.3, + "BN": 0.91, + "BT": 0.8, + "BW": 0.3, + "CF": 0.3, + "CA": 0.8, + "CH": 0.95, + "CL": 0.41, + "CN": 0.3, + "CI": 0.3, + "CM": 0.3, + "CD": 0.3, + "CG": 0.3, + "CO": 0.3, + "KM": 0.3, + "CV": 0.3, + "CR": 0.34, + "CU": 0.8, + "CW": 0.36, + "KY": 0.95, + "CY": 0.7, + "CZ": 0.68, + "DE": 0.87, + "DJ": 0.3, + "DM": 0.3, + "DK": 0.95, + "DO": 0.3, + "DZ": 0.3, + "EC": 0.3, + "EG": 0.3, + "ER": 0.8, + "ES": 0.64, + "EE": 0.63, + "ET": 0.3, + "FI": 0.82, + "FJ": 0.3, + "FR": 0.75, + "FO": 0.8, + "FM": 0.3, + "GA": 0.3, + "GB": 0.75, + "GE": 0.3, + "GH": 0.3, + "GI": 0.8, + "GN": 0.3, + "GM": 0.3, + "GW": 0.3, + "GQ": 0.3, + "GR": 0.51, + "GD": 0.3, + "GL": 0.8, + "GT": 0.3, + "GU": 0.8, + "GY": 0.55, + "HK": 0.9, + "HN": 0.3, + "HR": 0.55, + "HT": 0.3, + "HU": 0.57, + "ID": 0.3, + "IM": 0.8, + "IN": 0.3, + "IE": 0.95, + "IR": 0.3, + "IQ": 0.3, + "IS": 0.94, + "IL": 0.68, + "IT": 0.73, + "JM": 0.3, + "JO": 0.3, + "JP": 0.61, + "KZ": 0.4, + "KE": 0.3, + "KG": 0.3, + "KH": 0.3, + "KI": 0.3, + "KN": 0.45, + "KR": 0.68, + "KW": 0.76, + "LA": 0.3, + "LB": 0.8, + "LR": 0.8, + "LY": 0.31, + "LC": 0.3, + "LI": 0.8, + "LK": 0.3, + "LS": 0.3, + "LT": 0.67, + "LU": 0.95, + "LV": 0.55, + "MO": 0.8, + "MF": 0.8, + "MA": 0.3, + "MC": 0.8, + "MD": 0.3, + "MG": 0.3, + "MV": 0.33, + "MX": 0.31, + "MH": 0.3, + "MK": 0.3, + "ML": 0.3, + "MT": 0.77, + "MM": 0.3, + "ME": 0.37, + "MN": 0.3, + "MP": 0.8, + "MZ": 0.3, + "MR": 0.3, + "MU": 0.35, + "MW": 0.3, + "MY": 0.44, + "NA": 0.3, + "NC": 0.8, + "NE": 0.3, + "NG": 0.3, + "NI": 0.3, + "NL": 0.98, + "NO": 0.95, + "NP": 0.3, + "NR": 0.3, + "NZ": 0.69, + "OM": 0.55, + "PK": 0.3, + "PA": 0.51, + "PE": 0.3, + "PH": 0.3, + "PW": 0.8, + "PG": 0.3, + "PL": 0.61, + "PR": 0.53, + "KP": 0.8, + "PT": 0.58, + "PY": 0.3, + "PS": 0.3, + "PF": 0.8, + "QA": 0.95, + "RO": 0.57, + "RU": 0.45, + "RW": 0.3, + "SA": 0.78, + "SD": 0.3, + "SN": 0.3, + "SG": 0.95, + "SB": 0.3, + "SL": 0.3, + "SV": 0.3, + "SM": 0.8, + "SO": 0.3, + "RS": 0.33, + "SS": 0.8, + "ST": 0.3, + "SR": 0.3, + "SK": 0.54, + "SI": 0.67, + "SE": 0.89, + "SZ": 0.3, + "SX": 0.65, + "SC": 0.39, + "SY": 0.8, + "TC": 0.32, + "TD": 0.3, + "TG": 0.3, + "TH": 0.3, + "TJ": 0.3, + "TM": 0.8, + "TL": 0.3, + "TO": 0.8, + "TT": 0.36, + "TN": 0.3, + "TR": 0.5, + "TV": 0.3, + "TZ": 0.3, + "UG": 0.3, + "UA": 0.3, + "UY": 0.38, + "US": 0.95, + "UZ": 0.3, + "VC": 0.3, + "VE": 0.8, + "VG": 0.8, + "VI": 0.8, + "VN": 0.3, + "VU": 0.3, + "WS": 0.3, + "XK": 0.3, + "YE": 0.8, + "ZA": 0.3, + "ZM": 0.3, + "ZW": 0.3 +} diff --git a/src/app/vpn-detection/embed/page.tsx b/src/app/vpn-detection/embed/page.tsx new file mode 100644 index 00000000..e8094943 --- /dev/null +++ b/src/app/vpn-detection/embed/page.tsx @@ -0,0 +1,9 @@ +import { USE_CASES } from '../../../client/components/common/content'; +import { generateUseCaseMetadata } from '../../../client/components/common/seo'; +import { VpnDetectionUseCaseWrapped } from '../VpnDetectionUseCase'; + +export const metadata = generateUseCaseMetadata(USE_CASES.vpnDetection); + +export default function VpnDetectionPage() { + return ; +} diff --git a/src/app/vpn-detection/fingerprintLogoLowOpacitySquareBordered.svg b/src/app/vpn-detection/fingerprintLogoLowOpacitySquareBordered.svg new file mode 100644 index 00000000..f007a946 --- /dev/null +++ b/src/app/vpn-detection/fingerprintLogoLowOpacitySquareBordered.svg @@ -0,0 +1,42 @@ + + + + + + diff --git a/src/app/vpn-detection/page.tsx b/src/app/vpn-detection/page.tsx new file mode 100644 index 00000000..31d4340d --- /dev/null +++ b/src/app/vpn-detection/page.tsx @@ -0,0 +1,9 @@ +import { USE_CASES } from '../../client/components/common/content'; +import { generateUseCaseMetadata } from '../../client/components/common/seo'; +import { VpnDetectionUseCaseWrapped } from './VpnDetectionUseCase'; + +export const metadata = generateUseCaseMetadata(USE_CASES.vpnDetection); + +export default function VpnDetectionPage() { + return ; +} diff --git a/src/app/vpn-detection/vpnDetection.module.scss b/src/app/vpn-detection/vpnDetection.module.scss new file mode 100644 index 00000000..f907d3ec --- /dev/null +++ b/src/app/vpn-detection/vpnDetection.module.scss @@ -0,0 +1,67 @@ +div.wrapper { + max-width: 660px; + padding: rem(32px) rem(20px) rem(24px) rem(20px); + display: flex; + flex-direction: column; + gap: rem(24px); + + @include media('<=phoneLandscape') { + padding: rem(12px); + } +} + +.innerWrapper { + border: 1px solid v('gray-box-stroke'); + border-radius: rem(6px); + padding: rem(16px); + margin-top: rem(32px); + background-color: v('light-gray-background'); + + @include media('<=phone') { + margin-top: rem(16px); + } + + form { + display: flex; + flex-direction: column; + gap: rem(16px); + } +} + +.regionalPricingForm { + p { + color: v('dark-black'); + font-size: rem(16px); + line-height: 145%; + margin: 0px; + } + + .regionaPricingContainer { + display: flex; + gap: rem(8px); + justify-content: center; + padding: rem(12px) 0px; + + @include media('<=phone') { + flex-direction: column; + + button[type='submit'] { + width: 100%; + padding: rem(12px); + } + } + } +} + +.confirmOrderButton { + width: fit-content; + margin-left: auto; + margin-right: auto; + max-width: 100%; + border: none; + + color: v('dark-gray'); + font-size: rem(16px); + font-weight: 600; + line-height: 150%; +} diff --git a/src/client/components/common/Alert/Alert.tsx b/src/client/components/common/Alert/Alert.tsx index 37bb0d55..9410f916 100644 --- a/src/client/components/common/Alert/Alert.tsx +++ b/src/client/components/common/Alert/Alert.tsx @@ -1,5 +1,4 @@ import { FunctionComponent, PropsWithChildren } from 'react'; -import { Severity } from '../../../../server/checkResult'; import SuccessIcon from './sucess.svg'; import ErrorIcon from './error.svg'; import WarningIcon from './warning.svg'; @@ -12,6 +11,7 @@ import { CustomContentProps, VariantType, useSnackbar, SnackbarKey } from 'notis import React from 'react'; import Button from '../Button/Button'; import { CrossIconSvg } from '../../../img/crossIconSvg'; +import { Severity } from '../../../../server/checks'; type AlertProps = { severity: Severity; diff --git a/src/client/components/common/Cart/Cart.tsx b/src/client/components/common/Cart/Cart.tsx index 65f1f933..d772ebdd 100644 --- a/src/client/components/common/Cart/Cart.tsx +++ b/src/client/components/common/Cart/Cart.tsx @@ -54,13 +54,14 @@ type CartProps = { items: CartProduct[]; discount: number; taxPerItem: number; + discountLabel?: string; }; -export const Cart: FunctionComponent = ({ items, discount, taxPerItem }) => { +export const Cart: FunctionComponent = ({ items, discount, taxPerItem, discountLabel }) => { const subTotal = items.reduce((acc, item) => acc + item.price * item.count, 0); const totalCount = items.reduce((acc, item) => acc + item.count, 0); - const discountApplied = (subTotal * discount) / 100; const taxesApplied = taxPerItem * totalCount; + const discountApplied = ((subTotal + taxesApplied) * discount) / 100; const total = subTotal + taxesApplied - discountApplied; return ( @@ -76,13 +77,17 @@ export const Cart: FunctionComponent = ({ items, discount, taxPerItem {format$(subTotal)} -
- Taxes - {format$(taxesApplied)} -
+ {taxesApplied !== 0 && ( +
+ Taxes + {format$(taxesApplied)} +
+ )} {discount > 0 && (
- Coupon Discount {discount}% + + {discountLabel ?? 'Discount'} {discount}% + -{format$(discountApplied)}
)} diff --git a/src/client/components/common/Header/Header.tsx b/src/client/components/common/Header/Header.tsx index 7d515030..e2cb1cf1 100644 --- a/src/client/components/common/Header/Header.tsx +++ b/src/client/components/common/Header/Header.tsx @@ -1,3 +1,5 @@ +'use client'; + import React, { useState, useEffect } from 'react'; import Prism from 'prismjs'; import MobileNavbar from '../MobileNavbar/MobileNavbar'; diff --git a/src/client/components/common/UseCaseWrapper/UseCaseWrapper.tsx b/src/client/components/common/UseCaseWrapper/UseCaseWrapper.tsx index abb8a7ec..710beb46 100644 --- a/src/client/components/common/UseCaseWrapper/UseCaseWrapper.tsx +++ b/src/client/components/common/UseCaseWrapper/UseCaseWrapper.tsx @@ -1,3 +1,5 @@ +'use client'; + import { ElementRef, FunctionComponent, useRef, useState } from 'react'; import Container from '../Container'; import styles from './UseCaseWrapper.module.scss'; diff --git a/src/client/components/common/content.tsx b/src/client/components/common/content.tsx index 6e8d12ca..6fed085b 100644 --- a/src/client/components/common/content.tsx +++ b/src/client/components/common/content.tsx @@ -8,6 +8,7 @@ import PaywallIcon from '../../img/paywallIcon.svg'; import PersonalizationIcon from '../../img/personalizationIcon.svg'; import ScrapingIcon from '../../img/scrapingIcon.svg'; import FirewallIcon from '../../img/firewallIcon.svg'; +import VpnDetectionIcon from '../../img/vpnDetection.svg'; import SmsIcon from '../../img/smsIcon.svg'; import { ReactNode } from 'react'; import { RestartHint, RestartHintProps } from './UseCaseWrapper/RestartHint'; @@ -560,6 +561,68 @@ export const USE_CASES = { }, ], }, + vpnDetection: { + title: 'VPN Detection', + titleMeta: 'Fingerprint Use Cases | VPN Detection and Location Spoofing Prevention', + url: '/vpn-detection', + // articleUrl: 'TODO', + iconSvg: VpnDetectionIcon, + descriptionHomepage: [ +

+ Use Fingerprint VPN detection to detect visitors trying to spoof their location. Deploy location-based pricing + or content restrictions with confidence. +

, +

+ Use Sealed client results to protect your Fingerprint integration from tampering and reverse-engineering. +

, + ], + description: ( + <> +

+ Many web applications need to apply content restrictions or regional discounts based on the visitor's + geographical location. But tech-savvy users can simply turn on a VPN to appear to be somewhere else. +

+

+ Fingerprint{' '} + + VPN Detection + {' '} + allows you to detect if a visitor is using a virtual private network and spoofing their location. You can + prevent these visitors and other suspicious browsers from applying purchase-power-parity discounts or + accessing geographically restricted content. +

+ + ), + descriptionMeta: + 'Use Fingerprint VPN detection to detect visitors trying to spoof their location. Deploy location-based pricing or content restrictions with confidence.', + doNotMentionResetButton: true, + instructions: [ + <> + Click Activate regional pricing in the checkout form below. Assuming your VPN is off, you will get a + location-based discount. + , + <>Turn on your VPN and pick an exit node different from your true location., + <>Try activating the discount again. You will not get the discount while using a VPN., + ], + instructionsNote: ( + <> + This use case demo uses{' '} + Sealed client results to process the + identification data. This provides lower latency and stronger tampering protection compared to only using the + Server API. + + ), + moreResources: [ + { + url: 'https://fingerprint.com/blog/vpn-detection-how-it-works/', + type: 'Article', + title: 'How VPN Detection Works', + }, + ], + }, } as const satisfies Record; export const PLAYGROUND_METADATA: Pick< diff --git a/src/client/components/common/seo.tsx b/src/client/components/common/seo.tsx index 10da98ba..597fefed 100644 --- a/src/client/components/common/seo.tsx +++ b/src/client/components/common/seo.tsx @@ -1,15 +1,54 @@ import Head from 'next/head'; import { FunctionComponent } from 'react'; -import { PRODUCTION_URL } from './content'; +import { PRODUCTION_URL, UseCase } from './content'; +import { Metadata } from 'next'; -type HeadSEOProps = { +type SeoProps = { title: string; description: string; image?: string; path?: string; }; -export const SEO: FunctionComponent = ({ title, description, image, path }) => { +/** + * Generates Metadata object for Next `app` directory + * https://nextjs.org/docs/app/api-reference/functions/generate-metadata#metadata-fields + */ +export const generateMetadata = ({ title, description, image, path }: SeoProps): Metadata => { + const metaImage = image ?? `${PRODUCTION_URL}/fingerprintDefaultMetaImage.png`; + const metaUrl = `${PRODUCTION_URL}${path ?? ''}`; + return { + title, + description, + openGraph: { + type: 'website', + title, + description, + siteName: 'Fingerprint Use Cases', + images: [metaImage], + url: metaUrl, + }, + twitter: { + title, + description, + card: 'summary_large_image', + images: [metaImage], + site: metaUrl, + }, + }; +}; + +export const generateUseCaseMetadata = (useCase: UseCase): Metadata => + generateMetadata({ + title: useCase.titleMeta, + description: useCase.descriptionMeta, + path: useCase.url, + }); + +/** + * Generates next/Head tags for Next `pages` directory + */ +export const SEO: FunctionComponent = ({ title, description, image, path }) => { const metaImage = image ?? `${PRODUCTION_URL}/fingerprintDefaultMetaImage.png`; const metaUrl = `${PRODUCTION_URL}${path ?? ''}`; return ( diff --git a/src/client/hooks/useReset/useReset.tsx b/src/client/hooks/useReset/useReset.tsx index 3fb04c9a..82a78732 100644 --- a/src/client/hooks/useReset/useReset.tsx +++ b/src/client/hooks/useReset/useReset.tsx @@ -1,11 +1,13 @@ +'use client'; + import { useVisitorData } from '@fingerprintjs/fingerprintjs-pro-react'; import { useMutation } from 'react-query'; import { ResetRequest, ResetResponse } from '../../../pages/api/admin/reset'; import { useSnackbar } from 'notistack'; import styles from './userReset.module.scss'; -import { useRouter } from 'next/router'; import { PLAYGROUND_METADATA, USE_CASES } from '../../components/common/content'; import { TEST_IDS } from '../../testIDs'; +import { usePathname } from 'next/navigation'; type UseResetParams = { onError?: () => void; @@ -13,9 +15,9 @@ type UseResetParams = { }; export const useReset = ({ onError, onSuccess }: UseResetParams) => { - const { getData } = useVisitorData({ ignoreCache: true }); + const { getData } = useVisitorData({ ignoreCache: true }, { immediate: false }); const { enqueueSnackbar } = useSnackbar(); - const { asPath } = useRouter(); + const pathname = usePathname(); const resetMutation = useMutation( 'resetMutation', @@ -74,6 +76,10 @@ export const useReset = ({ onError, onSuccess }: UseResetParams) => { return { ...resetMutation, shouldDisplayResetButton: - asPath !== '/' && !asPath.startsWith(PLAYGROUND_METADATA.url) && !asPath.startsWith(USE_CASES.webScraping.url), + pathname && + pathname !== '/' && + !pathname.startsWith(PLAYGROUND_METADATA.url) && + !pathname.startsWith(USE_CASES.webScraping.url) && + !pathname.startsWith(USE_CASES.vpnDetection.url), }; }; diff --git a/src/client/hooks/useUnsealedResult.ts b/src/client/hooks/useUnsealedResult.ts new file mode 100644 index 00000000..aa3abcb5 --- /dev/null +++ b/src/client/hooks/useUnsealedResult.ts @@ -0,0 +1,19 @@ +import { EventResponse } from '@fingerprintjs/fingerprintjs-pro-server-api'; +import { useQuery } from 'react-query'; + +export const useUnsealedResult = (sealedResult?: string) => { + return useQuery({ + queryKey: ['event', sealedResult], + queryFn: async () => { + const response = await fetch('/api/decrypt', { + method: 'POST', + body: JSON.stringify({ sealedResult }), + }); + if (response.status !== 200) { + throw new Error('Failed to unseal result: ' + response.statusText); + } + return await response.json(); + }, + enabled: Boolean(sealedResult), + }); +}; diff --git a/src/client/img/vpnDetection.svg b/src/client/img/vpnDetection.svg new file mode 100644 index 00000000..f67a6169 --- /dev/null +++ b/src/client/img/vpnDetection.svg @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + diff --git a/src/client/testIDs.ts b/src/client/testIDs.ts index a8c9875b..0b6b5a09 100644 --- a/src/client/testIDs.ts +++ b/src/client/testIDs.ts @@ -80,6 +80,10 @@ export const TEST_IDS = { copyCodeButton: 'copyCodeButton', codeInsideSnackbar: 'codeInsideSnackbar', }, + vpnDetection: { + callout: 'callout', + activateRegionalPricing: 'activateRegionalPricing', + }, } as const; export const TEST_ATTRIBUTES = { diff --git a/src/env.ts b/src/env.ts new file mode 100644 index 00000000..b76af7cd --- /dev/null +++ b/src/env.ts @@ -0,0 +1,118 @@ +import { createEnv } from '@t3-oss/env-nextjs'; +import { z } from 'zod'; + +/** + * In this file we manage environment variables using [t3-env](https://env.t3.gg/docs/introduction). + * This is basically the same as just importing them like `process.env.VARIABLE` but: + * - It provides a strongly typed `ENV` object so typos will result in TypeScript errors + * - If a required environment variable is missing, it will throw an error at build time instead of runtime + * - Plus other useful features like Zod validation and transformations, defaults, server vs client checks.... + **/ +export const env = createEnv({ + /* + * Server-side Environment variables, not available on the client. + * Will throw error if you access these variables on the client. + * + * Some default values are defined here to provide users with a "git-clone-and-it-just-works" experience when trying the demo, + * with other protections in place to prevent abuse. This only makes sense in an education demo project like this one. + * DO NOT expose your server-side secrets in your source code! + */ + server: { + // Main Fingerprint configuration + SERVER_API_KEY: z.string().min(1).default('fMUtVoWHKddpfOheQww2'), + // Lower confidence score limit for e2e tests + MIN_CONFIDENCE_SCORE: z.coerce.number().min(0.0).max(1.0).default(0.85), + + // Credential stuffing demo + KNOWN_VISITOR_IDS: z.string().min(1).default('').optional(), + + // Bot firewall use case Cloudflare settings + CLOUDFLARE_API_TOKEN: z.string().min(1).optional(), + CLOUDFLARE_ZONE_ID: z.string().min(1).optional(), + CLOUDFLARE_RULESET_ID: z.string().min(1).optional(), + + // SMS Pumping use case + TWILIO_API_KEY_SID: z.string().min(1).optional(), + TWILIO_API_KEY_SECRET: z.string().min(1).optional(), + TWILIO_ACCOUNT_SID: z.string().min(1).optional(), + TWILIO_FROM_NUMBER: z.string().min(1).optional(), + + // VPN Detection demo feat. Sealed client results + SEALED_RESULTS_DECRYPTION_KEY: z.string().min(1).default('nAEUm/yALfMwWGWzUEXjXplocr8ouYjAhEJgRnBNRwA='), + SEALED_RESULTS_SERVER_API_KEY: z.string().min(1).default('cRg3axMS26qfkjcS7OFh'), + HASH_SALT: z.string().min(1).default('defaultSalt'), + }, + /* + * Environment variables available on the client (and server). + * 💡 You'll get type errors if these are not prefixed with NEXT_PUBLIC_. + */ + client: { + // Main Fingerprint configuration + NEXT_PUBLIC_API_KEY: z.string().min(1).default('lwIgYR2dpSJfW830B24h'), + NEXT_PUBLIC_REGION: z.enum(['eu', 'us', 'ap']).default('us'), + NEXT_PUBLIC_SCRIPT_URL_PATTERN: z + .string() + .min(1) + .default('https://metrics.fingerprinthub.com/web/v//loader_v.js'), + NEXT_PUBLIC_ENDPOINT: z.string().min(1).default('https://metrics.fingerprinthub.com'), + + // Fingerprint configuration for VPN Detection demo feat. Sealed client results + NEXT_PUBLIC_SEALED_RESULTS_PUBLIC_API_KEY: z.string().min(1).default('2lFEzpuyfqkfQ9KJgiqv'), + NEXT_PUBLIC_SEALED_RESULTS_SCRIPT_URL: z + .string() + .min(1) + .default( + 'https://staging.fingerprinthub.com/fp-sealed/agent?apiKey=&version=&loaderVersion=', + ), + NEXT_PUBLIC_SEALED_RESULTS_ENDPOINT: z + .string() + .min(1) + .default('https://staging.fingerprinthub.com/fp-sealed/result?region=us'), + }, + /* + * Due to how Next.js bundles environment variables on Edge and Client, + * we need to manually destructure them to make sure all are included in bundle. + * 💡 You'll get type errors if not all variables from `server` & `client` are included here. + */ + runtimeEnv: { + // Main Fingerprint configuration values (used for most use cases) + NEXT_PUBLIC_API_KEY: process.env.NEXT_PUBLIC_API_KEY, + NEXT_PUBLIC_SCRIPT_URL_PATTERN: process.env.NEXT_PUBLIC_SCRIPT_URL_PATTERN, + NEXT_PUBLIC_ENDPOINT: process.env.NEXT_PUBLIC_ENDPOINT, + NEXT_PUBLIC_REGION: process.env.NEXT_PUBLIC_REGION, + SERVER_API_KEY: process.env.SERVER_API_KEY, + + // E2E tests + MIN_CONFIDENCE_SCORE: process.env.MIN_CONFIDENCE_SCORE, + + // Credential stuffing demo + KNOWN_VISITOR_IDS: process.env.KNOWN_VISITOR_IDS, + + // Bot firewall use case Cloudflare settings + CLOUDFLARE_API_TOKEN: process.env.CLOUDFLARE_API_TOKEN, + CLOUDFLARE_ZONE_ID: process.env.CLOUDFLARE_ZONE_ID, + CLOUDFLARE_RULESET_ID: process.env.CLOUDFLARE_RULESET_ID, + + // SMS Pumping use case + TWILIO_API_KEY_SID: process.env.TWILIO_API_KEY_SID, + TWILIO_API_KEY_SECRET: process.env.TWILIO_API_KEY_SECRET, + TWILIO_ACCOUNT_SID: process.env.TWILIO_ACCOUNT_SID, + TWILIO_FROM_NUMBER: process.env.TWILIO_FROM_NUMBER, + + // VPN Detection demo feat. Sealed client results + SEALED_RESULTS_DECRYPTION_KEY: process.env.SEALED_RESULTS_DECRYPTION_KEY, + SEALED_RESULTS_SERVER_API_KEY: process.env.SEALED_RESULTS_SERVER_API_KEY, + NEXT_PUBLIC_SEALED_RESULTS_PUBLIC_API_KEY: process.env.NEXT_PUBLIC_SEALED_RESULTS_PUBLIC_API_KEY, + NEXT_PUBLIC_SEALED_RESULTS_SCRIPT_URL: process.env.NEXT_PUBLIC_SEALED_RESULTS_SCRIPT_URL, + NEXT_PUBLIC_SEALED_RESULTS_ENDPOINT: process.env.NEXT_PUBLIC_SEALED_RESULTS_ENDPOINT, + HASH_SALT: process.env.HASH_SALT, + }, + isServer: + // Comprehensive server check + // https://github.com/t3-oss/t3-env/issues/154 + typeof window === 'undefined' || + 'Deno' in window || + globalThis.process?.env?.['NODE_ENV'] === 'test' || + !!globalThis.process?.env?.['JEST_WORKER_ID'] || + !!globalThis.process?.env?.['VITEST_WORKER_ID'], +}); diff --git a/src/envShared.ts b/src/envShared.ts new file mode 100644 index 00000000..9cda7658 --- /dev/null +++ b/src/envShared.ts @@ -0,0 +1,9 @@ +/** + * Env variables shared between Next.js and Playwright + * Playwright cannot import ESM-only packages like `t3-env`. + * We have to access environment variables for Playwright tests manually 😔. + * https://github.com/microsoft/playwright/issues/23662 + */ +export const TEST_BUILD = Boolean(process.env.TEST_BUILD); +export const IS_PRODUCTION = Boolean(process.env.NODE_ENV === 'production'); +export const IS_DEVELOPMENT = Boolean(process.env.NODE_ENV === 'development'); diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 12d5073a..0b932691 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -1,78 +1,36 @@ import '../styles/global-styles.scss'; -import { QueryClient, QueryClientProvider } from 'react-query'; import Head from 'next/head'; -import { SnackbarProvider } from 'notistack'; import { FpjsProvider, FingerprintJSPro } from '@fingerprintjs/fingerprintjs-pro-react'; import { AppProps } from 'next/app'; -import Header from '../client/components/common/Header/Header'; -import { FunctionComponent, PropsWithChildren } from 'react'; -import DeploymentUtils from '../client/DeploymentUtils'; -import Footer from '../client/components/common/Footer/Footer'; -import styles from '../styles/layout.module.scss'; -import { PUBLIC_API_KEY, SCRIPT_URL_PATTERN, ENDPOINT, FRONTEND_REGION, CUSTOM_TLS_ENDPOINT } from '../server/const'; +import Providers from '../Providers'; +import { Layout } from '../Layout'; +import { env } from '../env'; -import { CloseSnackbarButton, CustomSnackbar } from '../client/components/common/Alert/Alert'; - -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - refetchOnWindowFocus: false, - }, - }, -}); - -export const FP_LOAD_OPTIONS: FingerprintJSPro.LoadOptions = { - apiKey: PUBLIC_API_KEY, - scriptUrlPattern: [SCRIPT_URL_PATTERN, FingerprintJSPro.defaultScriptUrlPattern], - endpoint: [ENDPOINT, FingerprintJSPro.defaultEndpoint], - region: FRONTEND_REGION, - tlsEndpoint: CUSTOM_TLS_ENDPOINT, -}; - -const Layout: FunctionComponent> = ({ children, embed }) => { - return ( -
- {embed ? null :
} -
{children}
- {embed ? null :
} -
- ); +const FP_LOAD_OPTIONS: FingerprintJSPro.LoadOptions = { + apiKey: env.NEXT_PUBLIC_API_KEY, + scriptUrlPattern: [env.NEXT_PUBLIC_SCRIPT_URL_PATTERN, FingerprintJSPro.defaultScriptUrlPattern], + endpoint: [env.NEXT_PUBLIC_ENDPOINT, FingerprintJSPro.defaultEndpoint], + region: env.NEXT_PUBLIC_REGION, }; export type CustomPageProps = { embed?: boolean }; function CustomApp({ Component, pageProps }: AppProps) { return ( - - } - maxSnack={4} - autoHideDuration={5000} - anchorOrigin={{ - horizontal: 'left', - vertical: 'bottom', - }} - Components={{ - default: CustomSnackbar, - success: CustomSnackbar, - error: CustomSnackbar, - warning: CustomSnackbar, - info: CustomSnackbar, - }} - > + <> + + + + Fingerprint Pro Use Cases + + - - - - Fingerprint Pro Use Cases - - - - + + ); } diff --git a/src/pages/api/admin/reset.ts b/src/pages/api/admin/reset.ts index ff83e176..0a31f939 100644 --- a/src/pages/api/admin/reset.ts +++ b/src/pages/api/admin/reset.ts @@ -1,4 +1,4 @@ -import { Severity, isValidPostRequest } from '../../../server/server'; +import { isValidPostRequest } from '../../../server/server'; import { LoginAttemptDbModel } from '../credential-stuffing/authenticate'; import { PaymentAttemptDbModel } from '../payment-fraud/place-order'; import { @@ -9,7 +9,7 @@ import { import { LoanRequestDbModel } from '../../../server/loan-risk/database'; import { ArticleViewDbModel } from '../../../server/paywall/database'; import { CouponClaimDbModel } from '../../../server/coupon-fraud/database'; -import { getAndValidateFingerprintResult } from '../../../server/checks'; +import { Severity, getAndValidateFingerprintResult } from '../../../server/checks'; import { NextApiRequest, NextApiResponse } from 'next'; import { deleteBlockedIp } from '../../../server/botd-firewall/blockedIpsDatabase'; import { syncFirewallRuleset } from '../../../server/botd-firewall/cloudflareApiHelper'; @@ -36,7 +36,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< const { requestId } = req.body as ResetRequest; // Get the full Identification result from Fingerprint Server API and validate its authenticity - const fingerprintResult = await getAndValidateFingerprintResult(requestId, req); + const fingerprintResult = await getAndValidateFingerprintResult({ requestId, req }); if (!fingerprintResult.okay) { res.status(403).send({ severity: 'error', message: fingerprintResult.error }); return; diff --git a/src/pages/api/bot-firewall/block-ip.ts b/src/pages/api/bot-firewall/block-ip.ts index d525cfe8..7445173e 100644 --- a/src/pages/api/bot-firewall/block-ip.ts +++ b/src/pages/api/bot-firewall/block-ip.ts @@ -2,10 +2,9 @@ import { NextApiRequest, NextApiResponse } from 'next'; import { deleteBlockedIp, saveBlockedIp } from '../../../server/botd-firewall/blockedIpsDatabase'; import { syncFirewallRuleset } from '../../../server/botd-firewall/cloudflareApiHelper'; import { isValidPostRequest } from '../../../server/server'; -import { getAndValidateFingerprintResult } from '../../../server/checks'; +import { Severity, getAndValidateFingerprintResult } from '../../../server/checks'; import { isIP } from 'is-ip'; import { ValidationResult } from '../../../shared/types'; -import { Severity } from '../../../server/checkResult'; export type BlockIpPayload = { ip: string; @@ -66,5 +65,5 @@ const isValidBlockIpRequest = async (requestId: string, ip: string, req: NextApi } // Get the full Identification result from Fingerprint Server API and validate its authenticity - return await getAndValidateFingerprintResult(requestId, req); + return await getAndValidateFingerprintResult({ requestId, req }); }; diff --git a/src/pages/api/coupon-fraud/claim.ts b/src/pages/api/coupon-fraud/claim.ts index f2c6c6fb..dca1c8fd 100644 --- a/src/pages/api/coupon-fraud/claim.ts +++ b/src/pages/api/coupon-fraud/claim.ts @@ -1,15 +1,9 @@ -import { Severity, isValidPostRequest } from '../../../server/server'; +import { isValidPostRequest } from '../../../server/server'; import { Op } from 'sequelize'; import { COUPON_CODES, CouponClaimDbModel, CouponCodeString } from '../../../server/coupon-fraud/database'; -import { getAndValidateFingerprintResult } from '../../../server/checks'; +import { Severity, getAndValidateFingerprintResult } from '../../../server/checks'; import { NextApiRequest, NextApiResponse } from 'next'; - -export const COUPON_FRAUD_COPY = { - doesNotExist: 'Provided coupon code does not exist.', - usedBefore: 'The visitor used this coupon before.', - usedAnotherCouponRecently: 'The visitor claimed another coupon recently.', - success: 'Coupon claimed', -} as const; +import { COUPON_FRAUD_COPY } from '../../../server/coupon-fraud/copy'; export type CouponClaimPayload = { couponCode: string; @@ -36,7 +30,7 @@ export default async function claimCouponHandler(req: NextApiRequest, res: NextA const { couponCode, requestId } = req.body as CouponClaimPayload; // Get the full Identification result from Fingerprint Server API and validate its authenticity - const fingerprintResult = await getAndValidateFingerprintResult(requestId, req); + const fingerprintResult = await getAndValidateFingerprintResult({ requestId, req }); if (!fingerprintResult.okay) { res.status(403).send({ severity: 'error', message: fingerprintResult.error }); return; diff --git a/src/pages/api/credential-stuffing/authenticate.ts b/src/pages/api/credential-stuffing/authenticate.ts index 89f38748..31de0f9b 100644 --- a/src/pages/api/credential-stuffing/authenticate.ts +++ b/src/pages/api/credential-stuffing/authenticate.ts @@ -17,6 +17,8 @@ import { } from '../../../server/checks'; import { sendForbiddenResponse, sendOkResponse } from '../../../server/response'; import { NextApiRequest, NextApiResponse } from 'next'; +import { CREDENTIAL_STUFFING_COPY } from '../../../server/credentialStuffing/copy'; +import { env } from '../../../env'; // Mocked user with leaked credentials associated with visitorIds. const mockedUser = { @@ -25,13 +27,6 @@ const mockedUser = { knownVisitorIds: getKnownVisitorIds(), }; -export const CREDENTIAL_STUFFING_COPY = { - tooManyAttempts: 'You had 5 or more attempts during the last 24 hours. This login attempt was not performed.', - differentVisitorIdUseMFA: - "Provided credentials are correct but we've never seen you logging in using this device. Confirm your identity with a second factor.", - invalidCredentials: 'Incorrect credentials, try again.', -} as const; - // Defines db model for login attempt. export const LoginAttemptDbModel = sequelize.define('login-attempt', { visitorId: { @@ -52,7 +47,7 @@ LoginAttemptDbModel.sync({ force: false }); function getKnownVisitorIds() { const defaultVisitorIds = ['bXbwuhCBRB9lLTK692vw', 'ABvLgKyH3fAr6uAjn0vq', 'BNvLgKyHefAr9iOjn0ul']; - const visitorIdsFromEnv = process.env.KNOWN_VISITOR_IDS?.split(','); + const visitorIdsFromEnv = env.KNOWN_VISITOR_IDS?.split(','); console.info(`Extracted ${visitorIdsFromEnv?.length ?? 0} visitorIds from env.`); diff --git a/src/pages/api/event/[requestId].ts b/src/pages/api/event/[requestId].ts index 871b1c70..bb2f214a 100644 --- a/src/pages/api/event/[requestId].ts +++ b/src/pages/api/event/[requestId].ts @@ -1,11 +1,12 @@ import { isEventError } from '@fingerprintjs/fingerprintjs-pro-server-api'; import { NextApiRequest, NextApiResponse } from 'next'; import { fingerprintServerApiClient } from '../../../server/fingerprint-server-api'; -import { ourOrigins } from '../../../server/server'; import Cors from 'cors'; +import { OUR_ORIGINS } from '../../../server/checks'; +import { IS_PRODUCTION } from '../../../envShared'; // Also allow our documentation to use the endpoint -const allowedOrigins = [...ourOrigins, 'https://dev.fingerprint.com']; +const allowedOrigins = [...OUR_ORIGINS, 'https://dev.fingerprint.com']; // We need to set up CORS for that https://github.com/expressjs/cors#configuration-options const cors = Cors({ @@ -39,7 +40,7 @@ export default async function getFingerprintEvent(req: NextApiRequest, res: Next * to protect your Public API key from unauthorized usage. */ const origin = req.headers['origin'] as string; - if (process.env.NODE_ENV === 'production' && !allowedOrigins.includes(origin)) { + if (IS_PRODUCTION && !allowedOrigins.includes(origin)) { res.status(403).send({ message: `Origin ${origin} is not allowed to call this endpoint` }); return; } diff --git a/src/pages/api/loan-risk/request-loan.ts b/src/pages/api/loan-risk/request-loan.ts index 37de17bd..4eb01ab6 100644 --- a/src/pages/api/loan-risk/request-loan.ts +++ b/src/pages/api/loan-risk/request-loan.ts @@ -5,13 +5,7 @@ import { messageSeverity } from '../../../server/server'; import { calculateLoanValues } from '../../../server/loan-risk/calculate-loan-values'; import { CheckResult, checkResultType } from '../../../server/checkResult'; import { RuleCheck } from '../../../server/checks'; - -export const LOAN_RISK_COPY = { - approved: 'Congratulations, your loan has been approved!', - incomeLow: 'Sorry, your monthly income is too low for this loan.', - inconsistentApplicationChallenged: - 'We are unable to approve your loan automatically since you had requested a loan with a different income or personal details before. We need to verify provided information manually this time. Please, reach out to our agent.', -} as const; +import { LOAN_RISK_COPY } from '../../../server/loan-risk/copy'; /** * Validates previous loan requests sent by a given user. diff --git a/src/pages/api/payment-fraud/place-order.ts b/src/pages/api/payment-fraud/place-order.ts index b437d008..d1dd9f4b 100644 --- a/src/pages/api/payment-fraud/place-order.ts +++ b/src/pages/api/payment-fraud/place-order.ts @@ -17,15 +17,7 @@ import { } from '../../../server/checks'; import { sendForbiddenResponse, sendOkResponse } from '../../../server/response'; import { NextApiRequest, NextApiResponse } from 'next'; - -export const PAYMENT_FRAUD_COPY = { - stolenCard: 'According to our records, you paid with a stolen card. We did not process the payment.', - tooManyUnsuccessfulPayments: - 'You placed more than 3 unsuccessful payment attempts during the last 365 days. This payment attempt was not performed.', - previousChargeback: 'You performed more than 1 chargeback during the last 1 year, we did not perform the payment.', - successfulPayment: 'Thank you for your payment. Everything is OK.', - incorrectCardDetails: 'Incorrect card details, try again.', -} as const; +import { PAYMENT_FRAUD_COPY } from '../../../server/paymentFraud/copy'; interface PaymentAttemptAttributes extends Model, InferCreationAttributes> { diff --git a/src/pages/api/sms-pumping/send-verification-sms.ts b/src/pages/api/sms-pumping/send-verification-sms.ts index 5f2ff1ea..5fb66fa3 100644 --- a/src/pages/api/sms-pumping/send-verification-sms.ts +++ b/src/pages/api/sms-pumping/send-verification-sms.ts @@ -1,6 +1,5 @@ import { NextApiRequest, NextApiResponse } from 'next'; -import { Severity } from '../../../server/checkResult'; -import { getAndValidateFingerprintResult } from '../../../server/checks'; +import { Severity, getAndValidateFingerprintResult } from '../../../server/checks'; import { isValidPostRequest } from '../../../server/server'; import { RealSmsPerVisitorModel, SmsVerificationDatabaseModel } from '../../../server/sms-pumping/database'; import { ONE_SECOND_MS, readableMilliseconds } from '../../../shared/timeUtils'; @@ -15,6 +14,7 @@ import { SMS_FRAUD_COPY, TEST_PHONE_NUMBER, } from '../../../server/sms-pumping/smsPumpingConst'; +import { env } from '../../../env'; export type SendSMSPayload = { requestId: string; @@ -52,10 +52,10 @@ const sendSms = async (phone: string, body: string, visitorId: string) => { }, }); - const apiKeySid = process.env.TWILIO_API_KEY_SID; - const apiKeySecret = process.env.TWILIO_API_KEY_SECRET; - const accountSid = process.env.TWILIO_ACCOUNT_SID; - const fromNumber = process.env.TWILIO_FROM_NUMBER; + const apiKeySid = env.TWILIO_API_KEY_SID; + const apiKeySecret = env.TWILIO_API_KEY_SECRET; + const accountSid = env.TWILIO_ACCOUNT_SID; + const fromNumber = env.TWILIO_FROM_NUMBER; if (!apiKeySid) { throw new Error('Twilio API key SID not found.'); @@ -95,9 +95,13 @@ export default async function sendVerificationSMS(req: NextApiRequest, res: Next const { phoneNumber: phone, email, requestId, disableBotDetection } = req.body as SendSMSPayload; // Get the full identification Fingerprint Server API, check it authenticity and filter away Bot and Tor requests - const fingerprintResult = await getAndValidateFingerprintResult(requestId, req, { - blockBots: !disableBotDetection, - blockTor: true, + const fingerprintResult = await getAndValidateFingerprintResult({ + requestId, + req, + options: { + blockBots: !disableBotDetection, + blockTor: true, + }, }); if (!fingerprintResult.okay) { res.status(403).send({ severity: 'error', message: fingerprintResult.error }); diff --git a/src/pages/api/sms-pumping/submit-code.ts b/src/pages/api/sms-pumping/submit-code.ts index b0c11df8..f327a290 100644 --- a/src/pages/api/sms-pumping/submit-code.ts +++ b/src/pages/api/sms-pumping/submit-code.ts @@ -1,6 +1,5 @@ import { NextApiRequest, NextApiResponse } from 'next'; -import { Severity } from '../../../server/checkResult'; -import { getAndValidateFingerprintResult } from '../../../server/checks'; +import { Severity, getAndValidateFingerprintResult } from '../../../server/checks'; import { isValidPostRequest } from '../../../server/server'; import { SmsVerificationDatabaseModel } from '../../../server/sms-pumping/database'; import { Op } from 'sequelize'; @@ -29,7 +28,7 @@ export default async function sendVerificationSMS(req: NextApiRequest, res: Next const { phoneNumber, code, requestId } = req.body as SubmitCodePayload; // Get the full identification result from Fingerprint Server API and validate its authenticity - const fingerprintResult = await getAndValidateFingerprintResult(requestId, req); + const fingerprintResult = await getAndValidateFingerprintResult({ requestId, req }); if (!fingerprintResult.okay) { res.status(403).send({ severity: 'error', message: fingerprintResult.error }); return; diff --git a/src/pages/api/web-scraping/flights.ts b/src/pages/api/web-scraping/flights.ts index 5d70200d..f7b4236d 100644 --- a/src/pages/api/web-scraping/flights.ts +++ b/src/pages/api/web-scraping/flights.ts @@ -1,6 +1,5 @@ import { NextApiRequest, NextApiResponse } from 'next'; -import { Severity } from '../../../server/checkResult'; -import { getAndValidateFingerprintResult } from '../../../server/checks'; +import { Severity, getAndValidateFingerprintResult } from '../../../server/checks'; import { isValidPostRequest } from '../../../server/server'; import { ONE_DAY_MS, FIVE_MINUTES_MS, ONE_HOUR_MS } from '../../../shared/timeUtils'; import { AIRPORTS } from '../../web-scraping'; @@ -33,7 +32,7 @@ export default async function getFlights(req: NextApiRequest, res: NextApiRespon const { from, to, requestId, disableBotDetection } = req.body as FlightQuery; // Get the full Identification and Bot Detection result from Fingerprint Server API and validate its authenticity - const fingerprintResult = await getAndValidateFingerprintResult(requestId, req); + const fingerprintResult = await getAndValidateFingerprintResult({ requestId, req }); if (!fingerprintResult.okay) { res.status(403).send({ severity: 'error', message: fingerprintResult.error }); return; diff --git a/src/pages/coupon-fraud/index.tsx b/src/pages/coupon-fraud/index.tsx index bf652002..2c375188 100644 --- a/src/pages/coupon-fraud/index.tsx +++ b/src/pages/coupon-fraud/index.tsx @@ -84,7 +84,7 @@ export default function CouponFraudUseCase({ embed }: CustomPageProps) { return (
- +
{ diff --git a/src/pages/payment-fraud/index.tsx b/src/pages/payment-fraud/index.tsx index dc17babb..cb36d9e0 100644 --- a/src/pages/payment-fraud/index.tsx +++ b/src/pages/payment-fraud/index.tsx @@ -9,9 +9,9 @@ import formStyles from '../../styles/forms.module.scss'; import { Alert } from '../../client/components/common/Alert/Alert'; import { CustomPageProps } from '../_app'; import classNames from 'classnames'; -import { Severity } from '../../server/checkResult'; import { useVisitorData } from '@fingerprintjs/fingerprintjs-pro-react'; import { TEST_IDS } from '../../client/testIDs'; +import { Severity } from '../../server/checks'; export default function Index({ embed }: CustomPageProps) { const { getData } = useVisitorData( diff --git a/src/pages/playground/index.tsx b/src/pages/playground/index.tsx index c83090bb..3d238570 100644 --- a/src/pages/playground/index.tsx +++ b/src/pages/playground/index.tsx @@ -13,7 +13,7 @@ import IpBlocklistResult from '../../client/components/playground/IpBlocklistRes import VpnDetectionResult from '../../client/components/playground/VpnDetectionResult'; import { FormatIpAddress } from '../../client/components/playground/ipFormatUtils'; import { usePlaygroundSignals } from '../../client/components/playground/usePlaygroundSignals'; -import { getLocationName } from '../../shared/utils/getLocationName'; +import { getLocationName } from '../../shared/utils/locationUtils'; import { PLAYGROUND_TAG } from '../../client/components/playground/playgroundTags'; import { CustomPageProps } from '../_app'; import { PLAYGROUND_METADATA } from '../../client/components/common/content'; diff --git a/src/server/botd-firewall/cloudflareApiHelper.ts b/src/server/botd-firewall/cloudflareApiHelper.ts index a01da613..047afd80 100644 --- a/src/server/botd-firewall/cloudflareApiHelper.ts +++ b/src/server/botd-firewall/cloudflareApiHelper.ts @@ -1,12 +1,23 @@ +import { env } from '../../env'; import { getBlockedIps } from './blockedIpsDatabase'; import { CloudflareRule, buildFirewallRules } from './buildFirewallRules'; async function updateRulesetUsingCloudflareAPI(rules: CloudflareRule[]) { - const apiToken = process.env.CLOUDFLARE_API_TOKEN ?? ''; - const zoneId = process.env.CLOUDFLARE_ZONE_ID ?? ''; + const apiToken = env.CLOUDFLARE_API_TOKEN; + const zoneId = env.CLOUDFLARE_ZONE_ID; // You can get your Cloudflare API token, and zone ID from your Cloudflare dashboard. // But you might need to call the API to find the custom ruleset ID. See getCustomRulesetId() below. - const customRulesetId = process.env.CLOUDFLARE_RULESET_ID ?? ''; + const customRulesetId = env.CLOUDFLARE_RULESET_ID; + + if (!apiToken) { + throw new Error('No Cloudflare API token provided'); + } + if (!zoneId) { + throw new Error('No Cloudflare zone ID provided'); + } + if (!customRulesetId) { + throw new Error('No Cloudflare custom ruleset ID provided'); + } const url = `https://api.cloudflare.com/client/v4/zones/${zoneId}/rulesets/${customRulesetId}`; const options = { @@ -45,8 +56,15 @@ export const syncFirewallRuleset = async () => { // @ts-expect-error // eslint-disable-next-line @typescript-eslint/no-unused-vars async function getCustomRulesetId() { - const zoneId = process.env.CLOUDFLARE_ZONE_ID ?? ''; - const apiToken = process.env.CLOUDFLARE_API_TOKEN ?? ''; + const zoneId = env.CLOUDFLARE_ZONE_ID; + const apiToken = env.CLOUDFLARE_API_TOKEN; + if (!zoneId) { + throw new Error('No Cloudflare zone ID provided'); + } + if (!apiToken) { + throw new Error('No Cloudflare API token provided'); + } + try { const rulesets = await ( await fetch(`https://api.cloudflare.com/client/v4/zones/${zoneId}/rulesets`, { diff --git a/src/server/checkResult.ts b/src/server/checkResult.ts index 3959633e..0545bbc4 100644 --- a/src/server/checkResult.ts +++ b/src/server/checkResult.ts @@ -1,4 +1,4 @@ -export type Severity = import('./server').Severity; +import { Severity } from './checks'; export type CheckResultObject = { message: string; diff --git a/src/server/checks.ts b/src/server/checks.ts index b5e73325..442d71bc 100644 --- a/src/server/checks.ts +++ b/src/server/checks.ts @@ -1,15 +1,31 @@ -import { EventResponse, FingerprintJsServerApiClient, isEventError } from '@fingerprintjs/fingerprintjs-pro-server-api'; -import { CheckResult, checkResultType } from './checkResult'; import { - ALLOWED_REQUEST_TIMESTAMP_DIFF_MS, - BACKEND_REGION, - IPv4_REGEX, - MIN_CONFIDENCE_SCORE, - SERVER_API_KEY, -} from './const'; -import { messageSeverity, ourOrigins } from './server'; + EventResponse, + FingerprintJsServerApiClient, + Region, + isEventError, +} from '@fingerprintjs/fingerprintjs-pro-server-api'; +import { CheckResult, checkResultType } from './checkResult'; import { NextApiRequest, NextApiResponse } from 'next'; import { ValidationDataResult } from '../shared/types'; +import { decryptSealedResult } from './decryptSealedResult'; +import { env } from '../env'; +import { getServerRegion } from './fingerprint-server-api'; +import { IS_DEVELOPMENT } from '../envShared'; + +export const IPv4_REGEX = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.){3}(25[0-5]|(2[0-4]|1\d|[1-9]|)\d)$/; +export const ALLOWED_REQUEST_TIMESTAMP_DIFF_MS = 4000; + +// Demo origins. +// It is recommended to use production origins instead. +export const OUR_ORIGINS = [ + 'https://fingerprinthub.com', + 'https://demo.fingerprint.com', + 'https://localhost:3000', + 'http://localhost:3000', + 'https://staging.fingerprinthub.com', +]; + +export type Severity = 'success' | 'warning' | 'error'; // Validates format of visitorId and requestId. export const isVisitorIdFormatValid = (visitorId: string) => /^[a-zA-Z0-9]{20}$/.test(visitorId); @@ -40,7 +56,7 @@ export const checkFreshIdentificationRequest: RuleCheck = (eventResponse) => { if (!eventResponse || !timestamp) { return new CheckResult( 'Hmmm, sneaky trying to forge information from the client-side, no luck this time, no sensitive action was performed.', - messageSeverity.Error, + 'error', checkResultType.RequestIdMismatch, ); } @@ -48,11 +64,7 @@ export const checkFreshIdentificationRequest: RuleCheck = (eventResponse) => { const requestTimestampDiff = new Date().getTime() - timestamp; if (requestTimestampDiff > ALLOWED_REQUEST_TIMESTAMP_DIFF_MS) { - return new CheckResult( - 'Old requestId detected. Action ignored and logged.', - messageSeverity.Error, - checkResultType.OldTimestamp, - ); + return new CheckResult('Old requestId detected. Action ignored and logged.', 'error', checkResultType.OldTimestamp); } return undefined; @@ -63,10 +75,10 @@ export const checkFreshIdentificationRequest: RuleCheck = (eventResponse) => { */ export const checkConfidenceScore: RuleCheck = (eventResponse) => { const confidenceScore = eventResponse?.products?.identification?.data?.confidence.score; - if (!confidenceScore || confidenceScore < MIN_CONFIDENCE_SCORE) { + if (!confidenceScore || confidenceScore < env.MIN_CONFIDENCE_SCORE) { return new CheckResult( "Low confidence score, we'd rather verify you with the second factor,", - messageSeverity.Error, + 'error', checkResultType.LowConfidenceScore, ); } @@ -81,7 +93,7 @@ export const checkIpAddressIntegrity: RuleCheck = (eventResponse, request) => { if (!visitIpMatchesRequestIp(eventResponse.products?.identification?.data?.ip, request)) { return new CheckResult( 'IP mismatch. An attacker might have tried to phish the victim.', - messageSeverity.Error, + 'error', checkResultType.IpMismatch, ); } @@ -96,7 +108,7 @@ export const checkOriginsIntegrity: RuleCheck = (eventResponse, request) => { if (!originIsAllowed(eventResponse.products?.identification?.data?.url, request)) { return new CheckResult( 'Origin mismatch. An attacker might have tried to phish the victim.', - messageSeverity.Error, + 'error', checkResultType.ForeignOrigin, ); } @@ -104,9 +116,17 @@ export const checkOriginsIntegrity: RuleCheck = (eventResponse, request) => { return undefined; }; -export function visitIpMatchesRequestIp(visitIp = '', request: NextApiRequest) { +const isRequest = (request: Request | NextApiRequest): request is Request => { + return typeof request.headers.get == 'function'; +}; + +const getHeader = (request: Request | NextApiRequest, header: string) => { + return isRequest(request) ? request.headers.get(header) : request.headers[header]; +}; + +export function visitIpMatchesRequestIp(visitIp = '', request: NextApiRequest | Request) { // This check is skipped on purpose in the Stackblitz and localhost environments. - if (process.env.NODE_ENV === 'development') { + if (IS_DEVELOPMENT) { return true; } @@ -119,7 +139,7 @@ export function visitIpMatchesRequestIp(visitIp = '', request: NextApiRequest) { * https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For * https://adam-p.ca/blog/2022/03/x-forwarded-for/. */ - const xForwardedFor = request.headers['x-forwarded-for']; + const xForwardedFor = getHeader(request, 'x-forwarded-for'); const requestIp = Array.isArray(xForwardedFor) ? xForwardedFor[0] : xForwardedFor?.split(',')[0] ?? ''; // IPv6 addresses are not supported yet, skip the check @@ -130,53 +150,88 @@ export function visitIpMatchesRequestIp(visitIp = '', request: NextApiRequest) { return requestIp === visitIp; } -export function originIsAllowed(url = '', request: NextApiRequest) { +export function originIsAllowed(url = '', request: NextApiRequest | Request) { // This check is skipped on purpose in the Stackblitz and localhost environments. - if (process.env.NODE_ENV === 'development') { + if (IS_DEVELOPMENT) { return true; } + const headerOrigin = getHeader(request, 'origin'); const visitDataOrigin = new URL(url).origin; return ( - visitDataOrigin === request.headers['origin'] && - ourOrigins.includes(visitDataOrigin) && - ourOrigins.includes(request.headers['origin']) + visitDataOrigin === headerOrigin && OUR_ORIGINS.includes(visitDataOrigin) && OUR_ORIGINS.includes(headerOrigin) ); } /** - * Retrieves the full Identification event from the Server API and validates its authenticity. + * Retrieves the full Identification event validates its authenticity. + * - If your account has [Sealed Results](https://dev.fingerprint.com/docs/sealed-client-results) turned on, you can pass + * the `sealedResult` parameter to the function and it will decrypt the result locally using your decryption key + * instead of calling Server API (this is generally faster and simpler than Server API). + * - If `sealedResult` is not provided or something goes wrong during decryption, the function falls back to using Server API. */ -export const getAndValidateFingerprintResult = async ( - requestId: string, - req: NextApiRequest, + +type GetFingerprintResultArgs = { + requestId: string; + req: NextApiRequest | Request; + sealedResult?: string; + serverApiKey?: string; + region?: Region; options?: { blockTor: boolean; blockBots: boolean; - }, -): Promise> => { - // Request ID must match the expected format - if (!isRequestIdFormatValid(requestId)) { - return { okay: false, error: 'Invalid request ID format.' }; + }; +}; + +export const getAndValidateFingerprintResult = async ({ + requestId, + req, + sealedResult, + serverApiKey: apiKey = env.SERVER_API_KEY, + region = getServerRegion(env.NEXT_PUBLIC_REGION), + options, +}: GetFingerprintResultArgs): Promise> => { + let identificationEvent: EventResponse | undefined; + + /** + * If a sealed result was provided, try to decrypt it. + * Fall back to Server API if sealed result is not available. + * If your account doesn't have Sealed Results turned on you can ignore/skip this step in your implementation. + **/ + if (sealedResult) { + try { + identificationEvent = await decryptSealedResult(sealedResult); + if (identificationEvent.products?.identification?.data?.requestId !== requestId) { + return { + okay: false, + error: 'Sealed result request ID does not match provided request ID, potential spoofing attack', + }; + } + } catch (error) { + console.error( + `Decrypting sealed result ${sealedResult.slice(0, 32)} failed on ${error}. Falling back to Server API to get the identification event`, + ); + } } /** + * If `sealedResult` was not provided or unsealing failed, use Server API to get the identification event. * The Server API must contain information about this specific identification request. * If not, the request might have been tampered with and we don't trust this identification attempt. * The Server API also allows you to access all available [Smart Signals](https://dev.fingerprint.com/docs/smart-signals-overview) */ - let identificationEvent: EventResponse; - try { - const client = new FingerprintJsServerApiClient({ region: BACKEND_REGION, apiKey: SERVER_API_KEY }); - const eventResponse = await client.getEvent(requestId); - identificationEvent = eventResponse; - } catch (error) { - console.error(error); - // Throw a specific error if the request ID is not found - if (isEventError(error) && error.status === 404) { - return { okay: false, error: 'Request ID not found, potential spoofing attack.' }; + if (!identificationEvent) { + try { + const client = new FingerprintJsServerApiClient({ region, apiKey }); + identificationEvent = await client.getEvent(requestId); + } catch (error) { + console.error(error); + // Throw a specific error if the request ID is not found + if (isEventError(error) && error.status === 404) { + return { okay: false, error: 'Request ID not found, potential spoofing attack.' }; + } + return { okay: false, error: String(error) }; } - return { okay: false, error: String(error) }; } // Identification event must contain identification data @@ -226,7 +281,7 @@ export const getAndValidateFingerprintResult = async ( * This is context-sensitive and less reliable than the binary checks above, that's why it is checked last. * More info: https://dev.fingerprint.com/docs/understanding-your-confidence-score */ - if (identification.confidence.score < MIN_CONFIDENCE_SCORE) { + if (identification.confidence.score < env.MIN_CONFIDENCE_SCORE) { return { okay: false, error: 'Identification confidence score too low, potential spoofing attack.' }; } diff --git a/src/server/const.ts b/src/server/const.ts deleted file mode 100644 index 5271b733..00000000 --- a/src/server/const.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { FingerprintJSPro } from '@fingerprintjs/fingerprintjs-pro-react'; -import { Region } from '@fingerprintjs/fingerprintjs-pro-server-api'; - -export const IPv4_REGEX = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.){3}(25[0-5]|(2[0-4]|1\d|[1-9]|)\d)$/; -export const ALLOWED_REQUEST_TIMESTAMP_DIFF_MS = 4000; - -// Confidence score thresholds might be different for different scenarios -export const MIN_CONFIDENCE_SCORE = process.env.MIN_CONFIDENCE_SCORE ? Number(process.env.MIN_CONFIDENCE_SCORE) : 0.85; - -const BackendRegionMap = { - eu: Region.EU, - ap: Region.AP, - us: Region.Global, -}; - -type AgentRegion = FingerprintJSPro.LoadOptions['region']; - -// Warning: In the real world The Server API key should be secretly stored in the environment variables/secrets. -// We are keeping it here just to make it easy to run the demo. -export const SERVER_API_KEY = process.env.PRIVATE_API_KEY ?? 'fMUtVoWHKddpfOheQww2'; -export const PUBLIC_API_KEY = process.env.NEXT_PUBLIC_API_KEY ?? 'lwIgYR2dpSJfW830B24h'; -export const FRONTEND_REGION: AgentRegion = (process.env.NEXT_PUBLIC_FRONTEND_REGION as AgentRegion) ?? 'us'; -export const BACKEND_REGION: Region = - BackendRegionMap[process.env.BACKEND_REGION as keyof typeof BackendRegionMap] ?? Region.Global; -export const SCRIPT_URL_PATTERN = - process.env.NEXT_PUBLIC_SCRIPT_URL_PATTERN ?? - 'https://metrics.fingerprinthub.com/web/v//loader_v.js'; -export const ENDPOINT = process.env.NEXT_PUBLIC_ENDPOINT ?? 'https://metrics.fingerprinthub.com'; -export const CUSTOM_TLS_ENDPOINT = process.env.NEXT_PUBLIC_CUSTOM_TLS_ENDPOINT; diff --git a/src/server/coupon-fraud/copy.ts b/src/server/coupon-fraud/copy.ts new file mode 100644 index 00000000..2842e59b --- /dev/null +++ b/src/server/coupon-fraud/copy.ts @@ -0,0 +1,6 @@ +export const COUPON_FRAUD_COPY = { + doesNotExist: 'Provided coupon code does not exist.', + usedBefore: 'The visitor used this coupon before.', + usedAnotherCouponRecently: 'The visitor claimed another coupon recently.', + success: 'Coupon claimed', +} as const; diff --git a/src/server/credentialStuffing/copy.ts b/src/server/credentialStuffing/copy.ts new file mode 100644 index 00000000..adfcdd05 --- /dev/null +++ b/src/server/credentialStuffing/copy.ts @@ -0,0 +1,6 @@ +export const CREDENTIAL_STUFFING_COPY = { + tooManyAttempts: 'You had 5 or more attempts during the last 24 hours. This login attempt was not performed.', + differentVisitorIdUseMFA: + "Provided credentials are correct but we've never seen you logging in using this device. Confirm your identity with a second factor.", + invalidCredentials: 'Incorrect credentials, try again.', +} as const; diff --git a/src/server/decryptSealedResult.ts b/src/server/decryptSealedResult.ts new file mode 100644 index 00000000..7a977f1e --- /dev/null +++ b/src/server/decryptSealedResult.ts @@ -0,0 +1,16 @@ +import { DecryptionAlgorithm, unsealEventsResponse } from '@fingerprintjs/fingerprintjs-pro-server-api'; +import { env } from '../env'; + +export const decryptSealedResult = async (sealedResult: string) => { + const decryptionKey = env.SEALED_RESULTS_DECRYPTION_KEY; + if (!decryptionKey) { + throw new Error('Missing SEALED_RESULTS_DECRYPTION_KEY env variable'); + } + + return unsealEventsResponse(Buffer.from(sealedResult, 'base64'), [ + { + key: Buffer.from(decryptionKey, 'base64'), + algorithm: DecryptionAlgorithm.Aes256Gcm, + }, + ]); +}; diff --git a/src/server/fingerprint-server-api.ts b/src/server/fingerprint-server-api.ts index c98d9608..7039bb9d 100644 --- a/src/server/fingerprint-server-api.ts +++ b/src/server/fingerprint-server-api.ts @@ -1,9 +1,19 @@ import { FingerprintJsServerApiClient, AuthenticationMode } from '@fingerprintjs/fingerprintjs-pro-server-api'; -import { BACKEND_REGION, SERVER_API_KEY } from './const'; +import { env } from '../env'; + +import { Region } from '@fingerprintjs/fingerprintjs-pro-server-api'; + +const backendRegionMap = { + eu: Region.EU, + ap: Region.AP, + us: Region.Global, +}; + +export const getServerRegion = (region: 'eu' | 'ap' | 'us') => backendRegionMap[region]; export const fingerprintServerApiClient = new FingerprintJsServerApiClient({ - apiKey: SERVER_API_KEY, - region: BACKEND_REGION, + apiKey: env.SERVER_API_KEY, + region: getServerRegion(env.NEXT_PUBLIC_REGION), // Temporary fix for StackBlitz, remove once Server API supports CORS authenticationMode: AuthenticationMode.QueryParameter, }); diff --git a/src/server/loan-risk/copy.ts b/src/server/loan-risk/copy.ts new file mode 100644 index 00000000..631c0a22 --- /dev/null +++ b/src/server/loan-risk/copy.ts @@ -0,0 +1,6 @@ +export const LOAN_RISK_COPY = { + approved: 'Congratulations, your loan has been approved!', + incomeLow: 'Sorry, your monthly income is too low for this loan.', + inconsistentApplicationChallenged: + 'We are unable to approve your loan automatically since you had requested a loan with a different income or personal details before. We need to verify provided information manually this time. Please, reach out to our agent.', +} as const; diff --git a/src/server/paymentFraud/copy.ts b/src/server/paymentFraud/copy.ts new file mode 100644 index 00000000..d5a9fdcc --- /dev/null +++ b/src/server/paymentFraud/copy.ts @@ -0,0 +1,8 @@ +export const PAYMENT_FRAUD_COPY = { + stolenCard: 'According to our records, you paid with a stolen card. We did not process the payment.', + tooManyUnsuccessfulPayments: + 'You placed more than 3 unsuccessful payment attempts during the last 365 days. This payment attempt was not performed.', + previousChargeback: 'You performed more than 1 chargeback during the last 1 year, we did not perform the payment.', + successfulPayment: 'Thank you for your payment. Everything is OK.', + incorrectCardDetails: 'Incorrect card details, try again.', +} as const; diff --git a/src/server/server-utils.ts b/src/server/server-utils.ts index 91e8d976..826092b4 100644 --- a/src/server/server-utils.ts +++ b/src/server/server-utils.ts @@ -1,8 +1,8 @@ import Crypto from 'crypto'; +import { env } from '../env'; export function hashString(phoneNumber: string) { - const salt = process.env.HASH_SALT || ''; const hash = Crypto.createHash('sha256'); - hash.update(phoneNumber + salt); + hash.update(phoneNumber + env.HASH_SALT); return hash.digest('hex'); } diff --git a/src/server/sms-pumping/smsPumpingConst.ts b/src/server/sms-pumping/smsPumpingConst.ts index 034a2956..d9d6095f 100644 --- a/src/server/sms-pumping/smsPumpingConst.ts +++ b/src/server/sms-pumping/smsPumpingConst.ts @@ -1,10 +1,10 @@ +import { TEST_BUILD } from '../../envShared'; import { ONE_SECOND_MS } from '../../shared/timeUtils'; import { pluralize } from '../../shared/utils'; export const TEST_PHONE_NUMBER = '+1234567890'; // Use smaller timeouts for test builds to speed up e2e tests -export const TEST_BUILD = Boolean(process.env.TEST_BUILD); export const SMS_ATTEMPT_TIMEOUT_MAP: Record = TEST_BUILD ? { 1: { timeout: 5 * ONE_SECOND_MS }, diff --git a/src/shared/utils/getLocationName.ts b/src/shared/utils/getLocationName.ts deleted file mode 100644 index ca44dbb7..00000000 --- a/src/shared/utils/getLocationName.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { EventResponseIpInfoV4Geolocation } from '../types'; - -export const UNKNOWN_LOCATION = 'Unknown'; -export function getLocationName(ipLocation?: EventResponseIpInfoV4Geolocation) { - const addressParts: string[] = []; - if (!ipLocation) { - return UNKNOWN_LOCATION; - } - const { city, country, subdivisions } = ipLocation; - if (city?.name) { - addressParts.push(city.name); - } - - if (subdivisions?.[0]?.name) { - addressParts.push(subdivisions[0].name); - } - - if (country) { - addressParts.push(country.name); - } - - if (addressParts.length === 0) { - return UNKNOWN_LOCATION; - } - - return addressParts.join(', '); -} diff --git a/src/shared/utils/getLocationName.test.ts b/src/shared/utils/locationUtils.test.ts similarity index 74% rename from src/shared/utils/getLocationName.test.ts rename to src/shared/utils/locationUtils.test.ts index e0eaf104..0adc1b5c 100644 --- a/src/shared/utils/getLocationName.test.ts +++ b/src/shared/utils/locationUtils.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { getLocationName, UNKNOWN_LOCATION } from './getLocationName'; +import { getFlagEmoji, getLocationName, UNKNOWN_LOCATION } from './locationUtils'; describe('getLocationName test', () => { it('Should return Unknown in case of undefined ipLocation', () => { @@ -47,3 +47,23 @@ describe('getLocationName test', () => { ).toBe('Columbus, Ohio, United States'); }); }); + +describe('getFlagEmoji test', () => { + const testCases = { + SK: '🇸🇰', + CZ: '🇨🇿', + US: '🇺🇸', + CA: '🇨🇦', + GB: '🇬🇧', + DE: '🇩🇪', + FR: '🇫🇷', + JP: '🇯🇵', + BR: '🇧🇷', + IN: '🇮🇳', + }; + Object.entries(testCases).forEach(([key, value]) => { + it(`Should return ${value} for ${key}`, () => { + expect(getFlagEmoji(key)).toBe(value); + }); + }); +}); diff --git a/src/shared/utils/locationUtils.ts b/src/shared/utils/locationUtils.ts new file mode 100644 index 00000000..a9a0ae58 --- /dev/null +++ b/src/shared/utils/locationUtils.ts @@ -0,0 +1,44 @@ +import { EventResponse } from '@fingerprintjs/fingerprintjs-pro-server-api'; +import { EventResponseIpInfoV4Geolocation } from '../types'; + +export const UNKNOWN_LOCATION = 'Unknown'; +export function getLocationName(ipLocation?: EventResponseIpInfoV4Geolocation, includeSubdivision = true) { + const addressParts: string[] = []; + if (!ipLocation) { + return UNKNOWN_LOCATION; + } + const { city, country, subdivisions } = ipLocation; + if (city?.name) { + addressParts.push(city.name); + } + + if (subdivisions?.[0]?.name && includeSubdivision) { + addressParts.push(subdivisions[0].name); + } + + if (country) { + addressParts.push(country.name); + } + + if (addressParts.length === 0) { + return UNKNOWN_LOCATION; + } + + return addressParts.join(', '); +} + +export const getIpLocation = (eventResponse?: EventResponse): EventResponseIpInfoV4Geolocation | undefined => { + return eventResponse?.products?.ipInfo?.data?.v4?.geolocation; +}; + +// Courtesy of https://dev.to/jorik/country-code-to-flag-emoji-a21 +export const getFlagEmoji = (countryCode?: string) => { + if (!countryCode) { + return ''; + } + const codePoints = countryCode + .toUpperCase() + .split('') + .map((char) => 127397 + char.charCodeAt(0)); + return String.fromCodePoint(...codePoints); +}; diff --git a/tsconfig.json b/tsconfig.json index e317cfee..9fd39d87 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,37 +2,26 @@ "extends": "@fingerprintjs/tsconfig-dx-team/tsconfig.json", "compilerOptions": { "target": "ES6", - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], + "lib": ["dom", "dom.iterable", "esnext"], "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "noEmit": true, "incremental": true, "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "node", + "module": "ESNext", + "moduleResolution": "Bundler", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", "plugins": [ { - "name": "next" - } + "name": "next", + }, ], "exactOptionalPropertyTypes": false, "allowJs": false, - "strictNullChecks": true + "strictNullChecks": true, }, - "include": [ - "next-env.d.ts", - "**/*.ts", - "**/*.tsx", - ".next/types/**/*.ts" - ], - "exclude": [ - "node_modules" - ] + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "src/env.ts"], + "exclude": ["node_modules"], } diff --git a/yarn.lock b/yarn.lock index dab2c212..1d19970c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1326,6 +1326,18 @@ dependencies: tslib "^2.4.0" +"@t3-oss/env-core@0.9.2": + version "0.9.2" + resolved "https://registry.yarnpkg.com/@t3-oss/env-core/-/env-core-0.9.2.tgz#4e45c23dc7d2491b8acc0791ed6092026be56fbd" + integrity sha512-KgWXljUTHgO3o7GMZQPAD5+P+HqpauMNNHowlm7V2b9IeMitSUpNKwG6xQrup/xARWHTdxRVIl0mSI4wCevQhQ== + +"@t3-oss/env-nextjs@^0.9.2": + version "0.9.2" + resolved "https://registry.yarnpkg.com/@t3-oss/env-nextjs/-/env-nextjs-0.9.2.tgz#c6702de1b4eb3fbd48587e7f3f030fb4c231eb93" + integrity sha512-dklHrgKLESStNVB67Jdbu6osxDYA+xNKaPBRerlnkEvzbCccSKMvZENx6EZebJuR4snqB3/yRykNMn/bdIAyiQ== + dependencies: + "@t3-oss/env-core" "0.9.2" + "@tootallnate/once@1": version "1.1.2" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82"