Skip to content

Commit

Permalink
Merge pull request #2058 from dubinc/track-visit
Browse files Browse the repository at this point in the history
PoC for /track/visit
  • Loading branch information
steven-tey authored Feb 23, 2025
2 parents 04ff0cf + 4d31bbc commit 29dba34
Show file tree
Hide file tree
Showing 5 changed files with 203 additions and 35 deletions.
40 changes: 6 additions & 34 deletions apps/web/app/api/track/click/route.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { verifyAnalyticsAllowedHostnames } from "@/lib/analytics/verify-analytics-allowed-hostnames";
import { DubApiError, handleAndReturnErrorResponse } from "@/lib/api/errors";
import { parseRequestBody } from "@/lib/api/utils";
import { conn } from "@/lib/planetscale/connection";
import { getLinkWithAllowedHostnames } from "@/lib/planetscale/get-link-with-allowed-hostnames";
import { recordClick } from "@/lib/tinybird";
import { ratelimit, redis } from "@/lib/upstash";
import { isValidUrl, LOCALHOST_IP, nanoid, punyEncode } from "@dub/utils";
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";
Expand All @@ -16,7 +17,7 @@ const CORS_HEADERS = {
"Access-Control-Allow-Headers": "Content-Type, Authorization",
};

// POST /api/track/click – Track a click event from client side
// POST /api/track/click – Track a click event from the client-side
export const POST = withAxiom(
async (req: AxiomRequest) => {
try {
Expand All @@ -42,18 +43,7 @@ export const POST = withAxiom(
});
}

const { rows } = await conn.execute<{
id: string;
url: string;
projectId: string;
allowedHostnames: string[];
}>(
"SELECT Link.id, Link.url, projectId, allowedHostnames FROM Link LEFT JOIN Project ON Link.projectId = Project.id WHERE domain = ? AND `key` = ?",
[domain, punyEncode(decodeURIComponent(key))],
);

const link =
rows && Array.isArray(rows) && rows.length > 0 ? rows[0] : null;
const link = await getLinkWithAllowedHostnames(domain, key);

if (!link) {
throw new DubApiError({
Expand All @@ -63,25 +53,7 @@ export const POST = withAxiom(
}

const allowedHostnames = link.allowedHostnames;

if (allowedHostnames && allowedHostnames.length > 0) {
const source = req.headers.get("referer") || req.headers.get("origin");
const sourceUrl = source ? new URL(source) : null;
const hostname = sourceUrl?.hostname.replace(/^www\./, "");

if (!hostname || !allowedHostnames.includes(hostname)) {
console.error("Hostname not allowed.", {
hostname,
allowedHostnames,
});
throw new DubApiError({
code: "forbidden",
message: `Hostname ${hostname} not included in allowed hostnames (${allowedHostnames.join(
", ",
)}).`,
});
}
}
verifyAnalyticsAllowedHostnames({ allowedHostnames, req });

const cacheKey = `recordClick:${link.id}:${ip}`;
let clickId = await redis.get<string>(cacheKey);
Expand Down
145 changes: 145 additions & 0 deletions apps/web/app/api/track/visit/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { verifyAnalyticsAllowedHostnames } from "@/lib/analytics/verify-analytics-allowed-hostnames";
import { DubApiError, handleAndReturnErrorResponse } from "@/lib/api/errors";
import { createId, parseRequestBody } from "@/lib/api/utils";
import { getLinkWithAllowedHostnames } from "@/lib/planetscale/get-link-with-allowed-hostnames";
import { recordClick, recordLink } from "@/lib/tinybird";
import { ratelimit, redis } from "@/lib/upstash";
import { prismaEdge } from "@dub/prisma/edge";
import {
isValidUrl,
linkConstructorSimple,
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 linkKey = urlObj.pathname.slice(1);
if (linkKey === "") {
linkKey = "_root";
}

const ip = process.env.VERCEL === "1" ? ipAddress(req) : LOCALHOST_IP;

const { success } = await ratelimit().limit(
`track-click:${domain}-${linkKey}:${ip}`,
);

if (!success) {
throw new DubApiError({
code: "rate_limit_exceeded",
message: "Don't DDoS me pls 🥺",
});
}

let link = await getLinkWithAllowedHostnames(domain, linkKey);

if (!link) {
// get root domain link
const rootDomainLink = await getLinkWithAllowedHostnames(
domain,
"_root",
);

if (!rootDomainLink) {
throw new DubApiError({
code: "not_found",
message: "siteDomain not found – have you created it on Dub yet?",
});
}

const newLink = await prismaEdge.link.create({
data: {
id: createId("link_"),
domain,
key: linkKey,
url: url.split("?")[0], // don't include query params when creating the link
shortLink: linkConstructorSimple({ domain, key: linkKey }),
trackConversion: true,
projectId: rootDomainLink.projectId,
folderId: rootDomainLink.folderId,
userId: rootDomainLink.userId,
},
});
// TODO: we might need to set redis cache when we start using redis to fetch link data
waitUntil(recordLink(newLink));

link = {
...newLink,
projectId: rootDomainLink.projectId,
folderId: rootDomainLink.folderId,
userId: rootDomainLink.userId,
allowedHostnames: rootDomainLink.allowedHostnames,
};
}

const allowedHostnames = link.allowedHostnames;
verifyAnalyticsAllowedHostnames({ allowedHostnames, req });

const cacheKey = `recordClick:${link.id}:${ip}`;
let clickId = await redis.get<string>(cacheKey);

if (!clickId) {
clickId = nanoid(16);
const finalUrl = isValidUrl(url) ? url : link.url;

waitUntil(
recordClick({
req,
clickId,
linkId: link.id,
url: finalUrl,
skipRatelimit: true,
workspaceId: link.projectId,
...(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,
});
};
28 changes: 28 additions & 0 deletions apps/web/lib/analytics/verify-analytics-allowed-hostnames.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { DubApiError } from "../api/errors";

export const verifyAnalyticsAllowedHostnames = ({
allowedHostnames,
req,
}: {
allowedHostnames: string[];
req: Request;
}) => {
if (allowedHostnames && allowedHostnames.length > 0) {
const source = req.headers.get("referer") || req.headers.get("origin");
const sourceUrl = source ? new URL(source) : null;
const hostname = sourceUrl?.hostname.replace(/^www\./, "");

if (!hostname || !allowedHostnames.includes(hostname)) {
console.error("Hostname not allowed.", {
hostname,
allowedHostnames,
});
throw new DubApiError({
code: "forbidden",
message: `Hostname ${hostname} not included in allowed hostnames (${allowedHostnames.join(
", ",
)}).`,
});
}
}
};
21 changes: 21 additions & 0 deletions apps/web/lib/planetscale/get-link-with-allowed-hostnames.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { punyEncode } from "@dub/utils";
import { conn } from "./connection";

export const getLinkWithAllowedHostnames = async (
domain: string,
key: string,
) => {
const { rows } = await conn.execute<{
id: string;
url: string;
projectId: string;
folderId: string | null;
userId: string;
allowedHostnames: string[];
}>(
"SELECT Link.id, Link.url, projectId, folderId, userId, allowedHostnames FROM Link LEFT JOIN Project ON Link.projectId = Project.id WHERE domain = ? AND `key` = ?",
[domain, punyEncode(decodeURIComponent(key))],
);

return rows && Array.isArray(rows) && rows.length > 0 ? rows[0] : null;
};
4 changes: 3 additions & 1 deletion apps/web/lib/tinybird/record-click.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export async function recordClick({
skipRatelimit,
workspaceId,
timestamp,
referrer,
}: {
req: Request;
linkId: string;
Expand All @@ -42,6 +43,7 @@ export async function recordClick({
skipRatelimit?: boolean;
workspaceId: string | undefined;
timestamp?: string;
referrer?: string;
}) {
const searchParams = new URL(req.url).searchParams;

Expand Down Expand Up @@ -89,7 +91,7 @@ export async function recordClick({
const isEuCountry = geo.country && EU_COUNTRY_CODES.includes(geo.country);

const ua = userAgent(req);
const referer = req.headers.get("referer");
const referer = referrer || req.headers.get("referer");

const identity_hash = await getIdentityHash(req);

Expand Down

0 comments on commit 29dba34

Please sign in to comment.