-
Notifications
You must be signed in to change notification settings - Fork 2.2k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #2058 from dubinc/track-visit
PoC for /track/visit
- Loading branch information
Showing
5 changed files
with
203 additions
and
35 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
28
apps/web/lib/analytics/verify-analytics-allowed-hostnames.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
21
apps/web/lib/planetscale/get-link-with-allowed-hostnames.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters