mirror of
https://github.com/umami-software/umami.git
synced 2025-12-06 01:18:00 +01:00
284 lines
7.5 KiB
TypeScript
284 lines
7.5 KiB
TypeScript
import { z } from 'zod';
|
|
import { isbot } from 'isbot';
|
|
import { startOfHour, startOfMonth } from 'date-fns';
|
|
import clickhouse from '@/lib/clickhouse';
|
|
import { parseRequest } from '@/lib/request';
|
|
import { badRequest, json, forbidden, serverError } from '@/lib/response';
|
|
import { fetchWebsite } from '@/lib/load';
|
|
import { getClientInfo, hasBlockedIp } from '@/lib/detect';
|
|
import { createToken, parseToken } from '@/lib/jwt';
|
|
import { secret, uuid, hash } from '@/lib/crypto';
|
|
import { COLLECTION_TYPE, EVENT_TYPE } from '@/lib/constants';
|
|
import { anyObjectParam, urlOrPathParam } from '@/lib/schema';
|
|
import { safeDecodeURI, safeDecodeURIComponent } from '@/lib/url';
|
|
import { createSession, saveEvent, saveSessionData } from '@/queries/sql';
|
|
import { serializeError } from 'serialize-error';
|
|
|
|
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(new Date().getTime() / 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 });
|
|
}
|
|
}
|