diff --git a/apps/web/app/api/track/click/route.ts b/apps/web/app/api/track/click/route.ts index 015d49fea..2f09158d9 100644 --- a/apps/web/app/api/track/click/route.ts +++ b/apps/web/app/api/track/click/route.ts @@ -3,7 +3,7 @@ import { DubApiError, handleAndReturnErrorResponse } from "@/lib/api/errors"; import { parseRequestBody } from "@/lib/api/utils"; import { getLinkWithAllowedHostnames } from "@/lib/planetscale/get-link-with-allowed-hostnames"; import { recordClick } from "@/lib/tinybird"; -import { ratelimit, redis } from "@/lib/upstash"; +import { redis } from "@/lib/upstash"; import { isValidUrl, LOCALHOST_IP, nanoid } from "@dub/utils"; import { ipAddress, waitUntil } from "@vercel/functions"; import { AxiomRequest, withAxiom } from "next-axiom"; @@ -21,7 +21,7 @@ const CORS_HEADERS = { export const POST = withAxiom( async (req: AxiomRequest) => { try { - const { domain, key, url } = await parseRequestBody(req); + const { domain, key, url, referrer } = await parseRequestBody(req); if (!domain || !key) { throw new DubApiError({ @@ -32,34 +32,25 @@ export const POST = withAxiom( const ip = process.env.VERCEL === "1" ? ipAddress(req) : LOCALHOST_IP; - const { success } = await ratelimit().limit( - `track-click:${domain}-${key}:${ip}`, - ); - - if (!success) { - throw new DubApiError({ - code: "rate_limit_exceeded", - message: "Don't DDoS me pls 🥺", - }); - } + const cacheKey = `recordClick:${domain}:${key}:${ip}`; + let clickId = await redis.get(cacheKey); - const link = await getLinkWithAllowedHostnames(domain, key); + // only generate + record a new click ID if it's not already cached in Redis + if (!clickId) { + clickId = nanoid(16); - if (!link) { - throw new DubApiError({ - code: "not_found", - message: `Link not found for domain: ${domain} and key: ${key}.`, - }); - } + const link = await getLinkWithAllowedHostnames(domain, key); - const allowedHostnames = link.allowedHostnames; - verifyAnalyticsAllowedHostnames({ allowedHostnames, req }); + if (!link) { + throw new DubApiError({ + code: "not_found", + message: `Link not found for domain: ${domain} and key: ${key}.`, + }); + } - const cacheKey = `recordClick:${link.id}:${ip}`; - let clickId = await redis.get(cacheKey); + const allowedHostnames = link.allowedHostnames; + verifyAnalyticsAllowedHostnames({ allowedHostnames, req }); - if (!clickId) { - clickId = nanoid(16); const finalUrl = isValidUrl(url) ? url : link.url; waitUntil( @@ -67,9 +58,12 @@ export const POST = withAxiom( req, clickId, linkId: link.id, + domain, + key, url: finalUrl, - skipRatelimit: true, workspaceId: link.projectId, + skipRatelimit: true, + ...(referrer && { referrer }), }), ); } diff --git a/apps/web/app/api/track/visit/route.ts b/apps/web/app/api/track/visit/route.ts new file mode 100644 index 000000000..4d751d06e --- /dev/null +++ b/apps/web/app/api/track/visit/route.ts @@ -0,0 +1,104 @@ +import { verifyAnalyticsAllowedHostnames } from "@/lib/analytics/verify-analytics-allowed-hostnames"; +import { DubApiError, handleAndReturnErrorResponse } from "@/lib/api/errors"; +import { parseRequestBody } from "@/lib/api/utils"; +import { getLinkWithAllowedHostnames } from "@/lib/planetscale/get-link-with-allowed-hostnames"; +import { recordClick } from "@/lib/tinybird"; +import { redis } from "@/lib/upstash"; +import { isValidUrl, LOCALHOST_IP, nanoid } from "@dub/utils"; +import { ipAddress, waitUntil } from "@vercel/functions"; +import { AxiomRequest, withAxiom } from "next-axiom"; +import { NextResponse } from "next/server"; + +export const runtime = "edge"; + +const CORS_HEADERS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", +}; + +// POST /api/track/visit – Track a visit event from the client-side +export const POST = withAxiom( + async (req: AxiomRequest) => { + try { + const { domain, url, referrer } = await parseRequestBody(req); + + if (!domain || !url) { + throw new DubApiError({ + code: "bad_request", + message: "Missing domain or url", + }); + } + + const urlObj = new URL(url); + + let key = urlObj.pathname.slice(1); + if (key === "") { + key = "_root"; + } + + const ip = process.env.VERCEL === "1" ? ipAddress(req) : LOCALHOST_IP; + const cacheKey = `recordClick:${domain}:${key}:${ip}`; + + let clickId = await redis.get(cacheKey); + + // only generate + record a new click ID if it's not already cached in Redis + if (!clickId) { + clickId = nanoid(16); + + let link = await getLinkWithAllowedHostnames(domain, key); + + if (!link) { + return NextResponse.json( + { + clickId: null, + }, + { + headers: CORS_HEADERS, + }, + ); + } + + const allowedHostnames = link.allowedHostnames; + verifyAnalyticsAllowedHostnames({ allowedHostnames, req }); + + const finalUrl = isValidUrl(url) ? url : link.url; + + waitUntil( + recordClick({ + req, + clickId, + linkId: link.id, + domain, + key, + url: finalUrl, + workspaceId: link.projectId, + skipRatelimit: true, + ...(referrer && { referrer }), + }), + ); + } + + return NextResponse.json( + { + clickId, + }, + { + headers: CORS_HEADERS, + }, + ); + } catch (error) { + return handleAndReturnErrorResponse(error, CORS_HEADERS); + } + }, + { + logRequestDetails: ["body", "nextUrl"], + }, +); + +export const OPTIONS = () => { + return new Response(null, { + status: 204, + headers: CORS_HEADERS, + }); +}; diff --git a/apps/web/lib/middleware/link.ts b/apps/web/lib/middleware/link.ts index bc384fb1e..a102091ba 100644 --- a/apps/web/lib/middleware/link.ts +++ b/apps/web/lib/middleware/link.ts @@ -196,8 +196,10 @@ export default async function LinkMiddleware( ev.waitUntil( recordClick({ req, - linkId, clickId, + linkId, + domain, + key, url, webhookIds, workspaceId, @@ -241,8 +243,10 @@ export default async function LinkMiddleware( ev.waitUntil( recordClick({ req, - linkId, clickId, + linkId, + domain, + key, url, webhookIds, workspaceId, @@ -275,8 +279,10 @@ export default async function LinkMiddleware( ev.waitUntil( recordClick({ req, - linkId, clickId, + linkId, + domain, + key, url, webhookIds, workspaceId, @@ -311,8 +317,10 @@ export default async function LinkMiddleware( ev.waitUntil( recordClick({ req, - linkId, clickId, + linkId, + domain, + key, url: ios, webhookIds, workspaceId, @@ -341,8 +349,10 @@ export default async function LinkMiddleware( ev.waitUntil( recordClick({ req, - linkId, clickId, + linkId, + domain, + key, url: android, webhookIds, workspaceId, @@ -371,8 +381,10 @@ export default async function LinkMiddleware( ev.waitUntil( recordClick({ req, - linkId, clickId, + linkId, + domain, + key, url: geo[country], webhookIds, workspaceId, @@ -401,8 +413,10 @@ export default async function LinkMiddleware( ev.waitUntil( recordClick({ req, - linkId, clickId, + linkId, + domain, + key, url, webhookIds, workspaceId, diff --git a/apps/web/lib/rewardful/import-referrals.ts b/apps/web/lib/rewardful/import-referrals.ts index e8416a49d..b3948001d 100644 --- a/apps/web/lib/rewardful/import-referrals.ts +++ b/apps/web/lib/rewardful/import-referrals.ts @@ -170,9 +170,11 @@ async function createReferral({ linkId: link.id, clickId: nanoid(16), url: link.url, + domain: link.domain, + key: link.key, workspaceId: workspace.id, - timestamp: new Date(referral.created_at).toISOString(), skipRatelimit: true, + timestamp: new Date(referral.created_at).toISOString(), }); const clickEvent = clickEventSchemaTB.parse({ diff --git a/apps/web/lib/tinybird/record-click.ts b/apps/web/lib/tinybird/record-click.ts index 7833e4816..ed9d14b40 100644 --- a/apps/web/lib/tinybird/record-click.ts +++ b/apps/web/lib/tinybird/record-click.ts @@ -26,22 +26,26 @@ import { transformClickEventData } from "../webhook/transform"; **/ export async function recordClick({ req, - linkId, clickId, + linkId, + domain, + key, url, webhookIds, - skipRatelimit, workspaceId, + skipRatelimit, timestamp, referrer, }: { req: Request; - linkId: string; clickId: string; + linkId: string; + domain: string; + key: string; url?: string; webhookIds?: string[]; - skipRatelimit?: boolean; workspaceId: string | undefined; + skipRatelimit?: boolean; timestamp?: string; referrer?: string; }) { @@ -61,10 +65,11 @@ export async function recordClick({ const ip = process.env.VERCEL === "1" ? ipAddress(req) : LOCALHOST_IP; - const cacheKey = `recordClick:${linkId}:${ip}`; + const cacheKey = `recordClick:${domain}:${key}:${ip}`; + // by default, we deduplicate clicks for a domain + key pair from the same IP address – only record 1 click per hour + // we only need to do these if skipRatelimit is not true (we skip it in /api/track/:path endpoints) if (!skipRatelimit) { - // by default, we deduplicate clicks from the same IP address + link ID – only record 1 click per hour // here, we check if the clickId is cached in Redis within the last hour const cachedClickId = await redis.get(cacheKey); if (cachedClickId) {