253 lines
7.7 KiB
TypeScript
253 lines
7.7 KiB
TypeScript
import { isbot } from 'isbot';
|
|
import { serializeError } from 'serialize-error';
|
|
import { z } from 'zod';
|
|
import { HEATMAP_EVENT_TYPE } from '@/lib/constants';
|
|
import { corsPreflight, withCorsHeaders } from '@/lib/cors';
|
|
import { secret } from '@/lib/crypto';
|
|
import { getClientInfo, hasBlockedIp } from '@/lib/detect';
|
|
import { parseToken } from '@/lib/jwt';
|
|
import { fetchAccount, fetchTeam } from '@/lib/load';
|
|
import { getRecorderConfig } from '@/lib/recorder';
|
|
import { getReplayEventCount } from '@/lib/replay';
|
|
import { parseRequest } from '@/lib/request';
|
|
import { badRequest, forbidden, json, payloadTooLarge, serverError } from '@/lib/response';
|
|
import { getWebsite } from '@/queries/prisma';
|
|
import { saveRecording } from '@/queries/sql';
|
|
import { saveHeatmapEvents } from '@/queries/sql/heatmap/saveHeatmapEvents';
|
|
|
|
interface Cache {
|
|
sessionId: string;
|
|
visitId: string;
|
|
}
|
|
|
|
const MAX_RECORD_REQUEST_BYTES = 1_000_000;
|
|
|
|
const schema = z.discriminatedUnion('type', [
|
|
z.object({
|
|
type: z.literal('record'),
|
|
payload: z.object({
|
|
website: z.uuid(),
|
|
events: z.array(z.any()).max(200),
|
|
timestamp: z.coerce.number().int().optional(),
|
|
}),
|
|
}),
|
|
z.object({
|
|
type: z.literal('heatmap'),
|
|
payload: z.object({
|
|
website: z.uuid(),
|
|
events: z
|
|
.array(
|
|
z.discriminatedUnion('type', [
|
|
z.object({
|
|
type: z.literal('click'),
|
|
url: z.string(),
|
|
x: z.coerce.number().optional(),
|
|
y: z.coerce.number().optional(),
|
|
pageX: z.coerce.number().optional(),
|
|
pageY: z.coerce.number().optional(),
|
|
pageW: z.coerce.number().optional(),
|
|
pageH: z.coerce.number().optional(),
|
|
viewportW: z.coerce.number().optional(),
|
|
viewportH: z.coerce.number().optional(),
|
|
timestamp: z.coerce.number().int().optional(),
|
|
}),
|
|
z.object({
|
|
type: z.literal('scroll'),
|
|
url: z.string(),
|
|
scrollPct: z.coerce.number().optional(),
|
|
pageW: z.coerce.number().optional(),
|
|
pageH: z.coerce.number().optional(),
|
|
viewportW: z.coerce.number().optional(),
|
|
viewportH: z.coerce.number().optional(),
|
|
timestamp: z.coerce.number().int().optional(),
|
|
}),
|
|
]),
|
|
)
|
|
.max(200),
|
|
timestamp: z.coerce.number().int().optional(),
|
|
}),
|
|
}),
|
|
]);
|
|
|
|
function getUrlPath(url: string) {
|
|
try {
|
|
return new URL(url).pathname || '/';
|
|
} catch {
|
|
return url.startsWith('/') ? url.split(/[?#]/)[0] || '/' : '/';
|
|
}
|
|
}
|
|
|
|
async function getRequestBodySize(request: Request): Promise<number | null> {
|
|
const contentLength = request.headers.get('content-length');
|
|
|
|
if (contentLength) {
|
|
const size = Number(contentLength);
|
|
|
|
if (Number.isFinite(size)) {
|
|
return size;
|
|
}
|
|
}
|
|
|
|
try {
|
|
const text = await request.clone().text();
|
|
|
|
return new TextEncoder().encode(text).length;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function OPTIONS() {
|
|
return corsPreflight();
|
|
}
|
|
|
|
export async function POST(request: Request) {
|
|
try {
|
|
const requestBodySize = await getRequestBodySize(request);
|
|
|
|
if (requestBodySize && requestBodySize > MAX_RECORD_REQUEST_BYTES) {
|
|
return withCorsHeaders(
|
|
payloadTooLarge({
|
|
reason: 'payload_too_large',
|
|
maxBytes: MAX_RECORD_REQUEST_BYTES,
|
|
size: requestBodySize,
|
|
}),
|
|
);
|
|
}
|
|
|
|
const { body, error } = await parseRequest(request, schema, { skipAuth: true });
|
|
|
|
if (error) {
|
|
return withCorsHeaders(error());
|
|
}
|
|
|
|
const { website: websiteId } = body.payload;
|
|
const events = body.payload.events;
|
|
const timestamp = body.payload.timestamp;
|
|
|
|
if (!events?.length) {
|
|
return withCorsHeaders(json({ ok: true }));
|
|
}
|
|
|
|
// Parse cache token to get session info
|
|
const cacheHeader = request.headers.get('x-umami-cache');
|
|
|
|
if (!cacheHeader) {
|
|
return withCorsHeaders(badRequest({ message: 'Missing session token.' }));
|
|
}
|
|
|
|
const cache = (await parseToken(cacheHeader, secret())) as Cache | null;
|
|
|
|
if (!cache?.sessionId || !cache?.visitId) {
|
|
return withCorsHeaders(badRequest({ message: 'Invalid session token.' }));
|
|
}
|
|
|
|
const { sessionId, visitId } = cache;
|
|
|
|
// Query directly to avoid stale Redis cache for recorderEnabled
|
|
const website = await getWebsite(websiteId);
|
|
|
|
if (!website) {
|
|
return withCorsHeaders(badRequest({ message: 'Website not found.' }));
|
|
}
|
|
|
|
const recorderConfig = getRecorderConfig(website.replayConfig);
|
|
const replayEnabled = recorderConfig.replayEnabled === true;
|
|
const heatmapEnabled = recorderConfig.heatmapEnabled === true;
|
|
|
|
if (!website.recorderEnabled) {
|
|
return withCorsHeaders(json({ ok: false, reason: 'recorder_disabled' }));
|
|
}
|
|
|
|
if (process.env.CLOUD_MODE) {
|
|
const account = website.teamId
|
|
? await fetchTeam(website.teamId)
|
|
: website.userId
|
|
? await fetchAccount(website.userId)
|
|
: null;
|
|
|
|
if (!account?.isBusiness && !account?.isNoBilling) {
|
|
return withCorsHeaders(forbidden({ message: 'Business subscription required.' }));
|
|
}
|
|
}
|
|
|
|
// Client info for bot/IP checks
|
|
const { ip, userAgent } = await getClientInfo(request, {});
|
|
|
|
if (!process.env.DISABLE_BOT_CHECK && isbot(userAgent)) {
|
|
return withCorsHeaders(json({ beep: 'boop' }));
|
|
}
|
|
|
|
if (hasBlockedIp(ip)) {
|
|
return withCorsHeaders(forbidden());
|
|
}
|
|
|
|
if (body.type === 'record') {
|
|
if (!replayEnabled) {
|
|
return withCorsHeaders(json({ ok: false, reason: 'replay_disabled' }));
|
|
}
|
|
|
|
const eventTimestamps = events
|
|
.map((e: any) => Number(e?.timestamp))
|
|
.filter((t: number) => Number.isFinite(t) && t > 0);
|
|
|
|
const fallbackMs = (timestamp || Math.floor(Date.now() / 1000)) * 1000;
|
|
const minTimestamp = eventTimestamps.length ? Math.min(...eventTimestamps) : fallbackMs;
|
|
const maxTimestamp = eventTimestamps.length ? Math.max(...eventTimestamps) : fallbackMs;
|
|
|
|
const startedAt = new Date(minTimestamp);
|
|
const endedAt = new Date(maxTimestamp);
|
|
const chunkIndex = timestamp || Math.floor(Date.now() / 1000);
|
|
|
|
await saveRecording({
|
|
websiteId,
|
|
sessionId,
|
|
visitId,
|
|
chunkIndex,
|
|
events,
|
|
eventCount: getReplayEventCount(events),
|
|
startedAt,
|
|
endedAt,
|
|
});
|
|
|
|
return withCorsHeaders(json({ ok: true }));
|
|
}
|
|
|
|
if (!heatmapEnabled) {
|
|
return withCorsHeaders(json({ ok: false, reason: 'heatmap_disabled' }));
|
|
}
|
|
|
|
try {
|
|
const fallbackMs = (timestamp || Math.floor(Date.now() / 1000)) * 1000;
|
|
const heatmapRows = events.map(event => ({
|
|
websiteId,
|
|
sessionId,
|
|
visitId,
|
|
eventType: event.type === 'click' ? HEATMAP_EVENT_TYPE.click : HEATMAP_EVENT_TYPE.scroll,
|
|
x: event.type === 'click' ? (event.x ?? null) : null,
|
|
y: event.type === 'click' ? (event.y ?? null) : null,
|
|
pageX: event.type === 'click' ? (event.pageX ?? null) : null,
|
|
pageY: event.type === 'click' ? (event.pageY ?? null) : null,
|
|
pageW: event.pageW ?? null,
|
|
viewportW: event.viewportW ?? null,
|
|
viewportH: event.viewportH ?? null,
|
|
pageH: event.pageH ?? null,
|
|
scrollPct: event.type === 'scroll' ? (event.scrollPct ?? null) : null,
|
|
urlPath: getUrlPath(event.url),
|
|
createdAt: new Date(event.timestamp ?? fallbackMs),
|
|
}));
|
|
|
|
if (heatmapRows.length) {
|
|
await saveHeatmapEvents(heatmapRows);
|
|
}
|
|
} catch (e) {
|
|
// eslint-disable-next-line no-console
|
|
console.log('heatmap save failed', serializeError(e));
|
|
}
|
|
|
|
return withCorsHeaders(json({ ok: true }));
|
|
} catch (e) {
|
|
return withCorsHeaders(serverError(e));
|
|
}
|
|
}
|