diff options
Diffstat (limited to 'src/app/api/send')
| -rw-r--r-- | src/app/api/send/route.ts | 284 |
1 files changed, 284 insertions, 0 deletions
diff --git a/src/app/api/send/route.ts b/src/app/api/send/route.ts new file mode 100644 index 0000000..a0becc2 --- /dev/null +++ b/src/app/api/send/route.ts @@ -0,0 +1,284 @@ +import { startOfHour, startOfMonth } from 'date-fns'; +import { isbot } from 'isbot'; +import { serializeError } from 'serialize-error'; +import { z } from 'zod'; +import clickhouse from '@/lib/clickhouse'; +import { COLLECTION_TYPE, EVENT_TYPE } from '@/lib/constants'; +import { hash, secret, uuid } from '@/lib/crypto'; +import { getClientInfo, hasBlockedIp } from '@/lib/detect'; +import { createToken, parseToken } from '@/lib/jwt'; +import { fetchWebsite } from '@/lib/load'; +import { parseRequest } from '@/lib/request'; +import { badRequest, forbidden, json, serverError } from '@/lib/response'; +import { anyObjectParam, urlOrPathParam } from '@/lib/schema'; +import { safeDecodeURI, safeDecodeURIComponent } from '@/lib/url'; +import { createSession, saveEvent, saveSessionData } from '@/queries/sql'; + +interface Cache { + websiteId: string; + sessionId: string; + visitId: string; + iat: number; +} + +const schema = z.object({ + type: z.enum(['event', 'identify']), + payload: z + .object({ + website: z.uuid().optional(), + link: z.uuid().optional(), + pixel: z.uuid().optional(), + data: anyObjectParam.optional(), + hostname: z.string().max(100).optional(), + language: z.string().max(35).optional(), + referrer: urlOrPathParam.optional(), + screen: z.string().max(11).optional(), + title: z.string().optional(), + url: urlOrPathParam.optional(), + name: z.string().max(50).optional(), + tag: z.string().max(50).optional(), + ip: z.string().optional(), + userAgent: z.string().optional(), + timestamp: z.coerce.number().int().optional(), + id: z.string().optional(), + browser: z.string().optional(), + os: z.string().optional(), + device: z.string().optional(), + }) + .refine( + data => { + const keys = [data.website, data.link, data.pixel]; + const count = keys.filter(Boolean).length; + return count === 1; + }, + { + message: 'Exactly one of website, link, or pixel must be provided', + path: ['website'], + }, + ), +}); + +export async function POST(request: Request) { + try { + const { body, error } = await parseRequest(request, schema, { skipAuth: true }); + + if (error) { + return error(); + } + + const { type, payload } = body; + + const { + website: websiteId, + pixel: pixelId, + link: linkId, + hostname, + screen, + language, + url, + referrer, + name, + data, + title, + tag, + timestamp, + id, + } = payload; + + const sourceId = websiteId || pixelId || linkId; + + // Cache check + let cache: Cache | null = null; + + if (websiteId) { + const cacheHeader = request.headers.get('x-umami-cache'); + + if (cacheHeader) { + const result = await parseToken(cacheHeader, secret()); + + if (result) { + cache = result; + } + } + + // Find website + if (!cache?.websiteId) { + const website = await fetchWebsite(websiteId); + + if (!website) { + return badRequest({ message: 'Website not found.' }); + } + } + } + + // Client info + const { ip, userAgent, device, browser, os, country, region, city } = await getClientInfo( + request, + payload, + ); + + // Bot check + if (!process.env.DISABLE_BOT_CHECK && isbot(userAgent)) { + return json({ beep: 'boop' }); + } + + // IP block + if (hasBlockedIp(ip)) { + return forbidden(); + } + + const createdAt = timestamp ? new Date(timestamp * 1000) : new Date(); + const now = Math.floor(Date.now() / 1000); + + const sessionSalt = hash(startOfMonth(createdAt).toUTCString()); + const visitSalt = hash(startOfHour(createdAt).toUTCString()); + + const sessionId = id ? uuid(sourceId, id) : uuid(sourceId, ip, userAgent, sessionSalt); + + // Create a session if not found + if (!clickhouse.enabled && !cache?.sessionId) { + await createSession({ + id: sessionId, + websiteId: sourceId, + browser, + os, + device, + screen, + language, + country, + region, + city, + distinctId: id, + createdAt, + }); + } + + // Visit info + let visitId = cache?.visitId || uuid(sessionId, visitSalt); + let iat = cache?.iat || now; + + // Expire visit after 30 minutes + if (!timestamp && now - iat > 1800) { + visitId = uuid(sessionId, visitSalt); + iat = now; + } + + if (type === COLLECTION_TYPE.event) { + const base = hostname ? `https://${hostname}` : 'https://localhost'; + const currentUrl = new URL(url, base); + + let urlPath = + currentUrl.pathname === '/undefined' ? '' : currentUrl.pathname + currentUrl.hash; + const urlQuery = currentUrl.search.substring(1); + const urlDomain = currentUrl.hostname.replace(/^www./, ''); + + let referrerPath: string; + let referrerQuery: string; + let referrerDomain: string; + + // UTM Params + const utmSource = currentUrl.searchParams.get('utm_source'); + const utmMedium = currentUrl.searchParams.get('utm_medium'); + const utmCampaign = currentUrl.searchParams.get('utm_campaign'); + const utmContent = currentUrl.searchParams.get('utm_content'); + const utmTerm = currentUrl.searchParams.get('utm_term'); + + // Click IDs + const gclid = currentUrl.searchParams.get('gclid'); + const fbclid = currentUrl.searchParams.get('fbclid'); + const msclkid = currentUrl.searchParams.get('msclkid'); + const ttclid = currentUrl.searchParams.get('ttclid'); + const lifatid = currentUrl.searchParams.get('li_fat_id'); + const twclid = currentUrl.searchParams.get('twclid'); + + if (process.env.REMOVE_TRAILING_SLASH) { + urlPath = urlPath.replace(/\/(?=(#.*)?$)/, ''); + } + + if (referrer) { + const referrerUrl = new URL(referrer, base); + + referrerPath = referrerUrl.pathname; + referrerQuery = referrerUrl.search.substring(1); + referrerDomain = referrerUrl.hostname.replace(/^www\./, ''); + } + + const eventType = linkId + ? EVENT_TYPE.linkEvent + : pixelId + ? EVENT_TYPE.pixelEvent + : name + ? EVENT_TYPE.customEvent + : EVENT_TYPE.pageView; + + await saveEvent({ + websiteId: sourceId, + sessionId, + visitId, + eventType, + createdAt, + + // Page + pageTitle: safeDecodeURIComponent(title), + hostname: hostname || urlDomain, + urlPath: safeDecodeURI(urlPath), + urlQuery, + referrerPath: safeDecodeURI(referrerPath), + referrerQuery, + referrerDomain, + + // Session + distinctId: id, + browser, + os, + device, + screen, + language, + country, + region, + city, + + // Events + eventName: name, + eventData: data, + tag, + + // UTM + utmSource, + utmMedium, + utmCampaign, + utmContent, + utmTerm, + + // Click IDs + gclid, + fbclid, + msclkid, + ttclid, + lifatid, + twclid, + }); + } else if (type === COLLECTION_TYPE.identify) { + if (data) { + await saveSessionData({ + websiteId, + sessionId, + sessionData: data, + distinctId: id, + createdAt, + }); + } + } + + const token = createToken({ websiteId, sessionId, visitId, iat }, secret()); + + return json({ cache: token, sessionId, visitId }); + } catch (e) { + const error = serializeError(e); + + // eslint-disable-next-line no-console + console.log(error); + + return serverError({ errorObject: error }); + } +} |