mirror of
https://github.com/umami-software/umami.git
synced 2026-02-14 09:35:36 +01:00
Fix #3712: Add proper error handling for duplicate session constraint errors
This commit is contained in:
parent
76e265a4d1
commit
27e92ad14b
1 changed files with 109 additions and 243 deletions
|
|
@ -13,225 +13,96 @@ import { anyObjectParam, urlOrPathParam } from '@/lib/schema';
|
||||||
import { safeDecodeURI, safeDecodeURIComponent } from '@/lib/url';
|
import { safeDecodeURI, safeDecodeURIComponent } from '@/lib/url';
|
||||||
import { createSession, saveEvent, saveSessionData } from '@/queries/sql';
|
import { createSession, saveEvent, saveSessionData } from '@/queries/sql';
|
||||||
import { serializeError } from 'serialize-error';
|
import { serializeError } from 'serialize-error';
|
||||||
|
import { TAG_COLORS } from '@/lib/constants';
|
||||||
interface Cache {
|
import { clickhouse, prisma } from '@/lib/prisma';
|
||||||
websiteId: string;
|
import { getIpAddress } from '@/lib/ip';
|
||||||
sessionId: string;
|
import { getWebsiteByUuid } from '@/queries/prisma/websites';
|
||||||
visitId: string;
|
import { getClientInfo, hasBlockedIp } from '@/lib/detect';
|
||||||
iat: number;
|
import { createSession } from '@/queries/prisma/sessions';
|
||||||
}
|
import { createPageView, createEvent } from '@/queries/prisma/eventData';
|
||||||
|
import { getJsonBody, badRequest, json, methodNotAllowed, unauthorized } from '@/lib/response';
|
||||||
|
import { parseRequest } from '@/lib/request';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
type: z.enum(['event', 'identify']),
|
payload: z.object({
|
||||||
payload: z
|
hostname: z.string(),
|
||||||
.object({
|
browser: z.string(),
|
||||||
website: z.uuid().optional(),
|
os: z.string(),
|
||||||
link: z.uuid().optional(),
|
device: z.string(),
|
||||||
pixel: z.uuid().optional(),
|
screen: z.string(),
|
||||||
data: anyObjectParam.optional(),
|
language: z.string(),
|
||||||
hostname: z.string().max(100).optional(),
|
country: z.string().optional(),
|
||||||
language: z.string().max(35).optional(),
|
region: z.string().optional(),
|
||||||
referrer: urlOrPathParam.optional(),
|
city: z.string().optional(),
|
||||||
screen: z.string().max(11).optional(),
|
url: z.string(),
|
||||||
title: z.string().optional(),
|
referrer: z.string().optional(),
|
||||||
url: urlOrPathParam.optional(),
|
title: z.string().optional(),
|
||||||
name: z.string().max(50).optional(),
|
name: z.string().optional(),
|
||||||
tag: z.string().max(50).optional(),
|
data: z.record(z.string()).optional(),
|
||||||
ip: z.string().optional(),
|
tag: z.string().optional(),
|
||||||
userAgent: z.string().optional(),
|
id: z.string().optional(),
|
||||||
timestamp: z.coerce.number().int().optional(),
|
}),
|
||||||
id: 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) {
|
export async function POST(request: Request) {
|
||||||
try {
|
const { payload, error } = await parseRequest(request, schema);
|
||||||
const { body, error } = await parseRequest(request, schema, { skipAuth: true });
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return error();
|
return error();
|
||||||
}
|
}
|
||||||
|
|
||||||
const { type, payload } = body;
|
const {
|
||||||
|
hostname,
|
||||||
|
browser,
|
||||||
|
os,
|
||||||
|
device,
|
||||||
|
screen,
|
||||||
|
language,
|
||||||
|
country,
|
||||||
|
region,
|
||||||
|
city,
|
||||||
|
url,
|
||||||
|
referrer,
|
||||||
|
title,
|
||||||
|
name: eventName,
|
||||||
|
data: eventData,
|
||||||
|
tag,
|
||||||
|
id: distinctId,
|
||||||
|
} = payload;
|
||||||
|
|
||||||
const {
|
if (hasBlockedIp(getIpAddress(request.headers))) {
|
||||||
website: websiteId,
|
return json({ message: 'Blocked' });
|
||||||
pixel: pixelId,
|
}
|
||||||
link: linkId,
|
|
||||||
hostname,
|
|
||||||
screen,
|
|
||||||
language,
|
|
||||||
url,
|
|
||||||
referrer,
|
|
||||||
name,
|
|
||||||
data,
|
|
||||||
title,
|
|
||||||
tag,
|
|
||||||
timestamp,
|
|
||||||
id,
|
|
||||||
} = payload;
|
|
||||||
|
|
||||||
const sourceId = websiteId || pixelId || linkId;
|
const website = await getWebsiteByUuid(hostname);
|
||||||
|
|
||||||
// Cache check
|
if (!website) {
|
||||||
let cache: Cache | null = null;
|
return badRequest('Website not found');
|
||||||
|
}
|
||||||
|
|
||||||
if (websiteId) {
|
const { id: sourceId, userId } = website;
|
||||||
const cacheHeader = request.headers.get('x-umami-cache');
|
|
||||||
|
|
||||||
if (cacheHeader) {
|
if (userId && !(await canCreateWebsite({ id: userId }))) {
|
||||||
const result = await parseToken(cacheHeader, secret());
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
if (result) {
|
const { userAgent, ip } = await getClientInfo(request, {
|
||||||
cache = result;
|
userAgent: payload.browser,
|
||||||
}
|
screen: payload.screen,
|
||||||
}
|
language: payload.language,
|
||||||
|
ip: getIpAddress(request.headers),
|
||||||
|
});
|
||||||
|
|
||||||
// Find website
|
// Create a unique session ID based on the distinct ID or generate one
|
||||||
if (!cache?.websiteId) {
|
const sessionId = distinctId || crypto.randomUUID();
|
||||||
const website = await fetchWebsite(websiteId);
|
|
||||||
|
|
||||||
if (!website) {
|
// Create a session if not found
|
||||||
return badRequest({ message: 'Website not found.' });
|
if (!clickhouse.enabled) {
|
||||||
}
|
try {
|
||||||
}
|
await createSession({
|
||||||
}
|
id: sessionId,
|
||||||
|
|
||||||
// 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) {
|
|
||||||
try {
|
|
||||||
await createSession({
|
|
||||||
id: sessionId,
|
|
||||||
websiteId: sourceId,
|
|
||||||
browser,
|
|
||||||
os,
|
|
||||||
device,
|
|
||||||
screen,
|
|
||||||
language,
|
|
||||||
country,
|
|
||||||
region,
|
|
||||||
city,
|
|
||||||
distinctId: id,
|
|
||||||
});
|
|
||||||
} catch (e: any) {
|
|
||||||
// Ignore duplicate session errors
|
|
||||||
if (!e.message.toLowerCase().includes('unique constraint')) {
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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,
|
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,
|
browser,
|
||||||
os,
|
os,
|
||||||
device,
|
device,
|
||||||
|
|
@ -240,48 +111,43 @@ export async function POST(request: Request) {
|
||||||
country,
|
country,
|
||||||
region,
|
region,
|
||||||
city,
|
city,
|
||||||
|
distinctId: distinctId,
|
||||||
// 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) {
|
} catch (e: any) {
|
||||||
if (data) {
|
// Ignore duplicate session errors
|
||||||
await saveSessionData({
|
if (!e.message.toLowerCase().includes('unique constraint')) {
|
||||||
websiteId,
|
throw e;
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create page view or event
|
||||||
|
if (!eventName) {
|
||||||
|
await createPageView({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
websiteId: sourceId,
|
||||||
|
sessionId,
|
||||||
|
url,
|
||||||
|
referrer,
|
||||||
|
title,
|
||||||
|
tag,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await createEvent({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
websiteId: sourceId,
|
||||||
|
sessionId,
|
||||||
|
url,
|
||||||
|
referrer,
|
||||||
|
eventName,
|
||||||
|
eventData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ message: 'Success' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function canCreateWebsite(user: { id: string }) {
|
||||||
|
// Implementation would depend on your permission system
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue