Pixel route.

This commit is contained in:
Mike Cao 2025-08-18 15:49:10 -07:00
parent 3c5c1e48e9
commit 0ac8bd41b6
10 changed files with 139 additions and 64 deletions

View file

@ -0,0 +1,45 @@
import { NextResponse } from 'next/server';
import { notFound } from '@/lib/response';
import { findPixel } from '@/queries';
import { POST } from '@/app/api/send/route';
const image = Buffer.from('R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw', 'base64');
export async function GET(request: Request, { params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const pixel = await findPixel({
where: {
slug,
},
});
if (!pixel) {
return notFound();
}
const payload = {
type: 'event',
payload: {
pixel: pixel.id,
url: request.url,
referrer: request.referrer,
},
};
const req = new Request(request.url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const res = await POST(req);
return new NextResponse(image, {
headers: {
'Content-Type': 'image/gif',
'Content-Length': image.length.toString(),
'x-umami-collect': JSON.stringify(res),
},
});
}

View file

View file

@ -47,7 +47,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ lin
return Response.json(result); return Response.json(result);
} catch (e: any) { } catch (e: any) {
if (e.message.includes('Unique constraint') && e.message.includes('slug')) { if (e.message.toLowerCase().includes('unique constraint') && e.message.includes('slug')) {
return badRequest('That slug is already taken.'); return badRequest('That slug is already taken.');
} }

View file

@ -46,7 +46,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ pix
return Response.json(pixel); return Response.json(pixel);
} catch (e: any) { } catch (e: any) {
if (e.message.includes('Unique constraint') && e.message.includes('slug')) { if (e.message.toLowerCase().includes('unique constraint') && e.message.includes('slug')) {
return badRequest('That slug is already taken.'); return badRequest('That slug is already taken.');
} }

View file

@ -1,4 +1,5 @@
import { z } from 'zod'; import { z } from 'zod';
import debug from 'debug';
import { isbot } from 'isbot'; import { isbot } from 'isbot';
import { startOfHour, startOfMonth } from 'date-fns'; import { startOfHour, startOfMonth } from 'date-fns';
import clickhouse from '@/lib/clickhouse'; import clickhouse from '@/lib/clickhouse';
@ -8,29 +9,52 @@ import { fetchWebsite } from '@/lib/load';
import { getClientInfo, hasBlockedIp } from '@/lib/detect'; import { getClientInfo, hasBlockedIp } from '@/lib/detect';
import { createToken, parseToken } from '@/lib/jwt'; import { createToken, parseToken } from '@/lib/jwt';
import { secret, uuid, hash } from '@/lib/crypto'; import { secret, uuid, hash } from '@/lib/crypto';
import { COLLECTION_TYPE } from '@/lib/constants'; import { COLLECTION_TYPE, EVENT_TYPE } from '@/lib/constants';
import { anyObjectParam, urlOrPathParam } from '@/lib/schema'; import { anyObjectParam, urlOrPathParam } from '@/lib/schema';
import { safeDecodeURI, safeDecodeURIComponent } from '@/lib/url'; import { safeDecodeURI, safeDecodeURIComponent } from '@/lib/url';
import { createSession, saveEvent, saveSessionData } from '@/queries'; import { createSession, saveEvent, saveSessionData } from '@/queries';
const log = debug('umami:send');
interface Cache {
websiteId: string;
sessionId: string;
visitId: string;
iat: number;
}
const schema = z.object({ const schema = z.object({
type: z.enum(['event', 'identify']), type: z.enum(['event', 'identify']),
payload: z.object({ payload: z
website: z.string().uuid(), .object({
data: anyObjectParam.optional(), website: z.string().uuid().optional(),
hostname: z.string().max(100).optional(), link: z.string().uuid().optional(),
language: z.string().max(35).optional(), pixel: z.string().uuid().optional(),
referrer: urlOrPathParam.optional(), data: anyObjectParam.optional(),
screen: z.string().max(11).optional(), hostname: z.string().max(100).optional(),
title: z.string().optional(), language: z.string().max(35).optional(),
url: urlOrPathParam.optional(), referrer: urlOrPathParam.optional(),
name: z.string().max(50).optional(), screen: z.string().max(11).optional(),
tag: z.string().max(50).optional(), title: z.string().optional(),
ip: z.string().ip().optional(), url: urlOrPathParam.optional(),
userAgent: z.string().optional(), name: z.string().max(50).optional(),
timestamp: z.coerce.number().int().optional(), tag: z.string().max(50).optional(),
id: z.string().optional(), ip: z.string().ip().optional(),
}), userAgent: 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) {
@ -45,6 +69,8 @@ export async function POST(request: Request) {
const { const {
website: websiteId, website: websiteId,
pixel: pixelId,
link: linkId,
hostname, hostname,
screen, screen,
language, language,
@ -59,23 +85,26 @@ export async function POST(request: Request) {
} = payload; } = payload;
// Cache check // Cache check
let cache: { websiteId: string; sessionId: string; visitId: string; iat: number } | null = null; let cache: Cache | null = null;
const cacheHeader = request.headers.get('x-umami-cache');
if (cacheHeader) { if (websiteId) {
const result = await parseToken(cacheHeader, secret()); const cacheHeader = request.headers.get('x-umami-cache');
if (result) { if (cacheHeader) {
cache = result; const result = await parseToken(cacheHeader, secret());
if (result) {
cache = result;
}
} }
}
// Find website // Find website
if (!cache?.websiteId) { if (!cache?.websiteId) {
const website = await fetchWebsite(websiteId); const website = await fetchWebsite(websiteId);
if (!website) { if (!website) {
return badRequest('Website not found.'); return badRequest('Website not found.');
}
} }
} }
@ -105,22 +134,19 @@ export async function POST(request: Request) {
// Create a session if not found // Create a session if not found
if (!clickhouse.enabled && !cache?.sessionId) { if (!clickhouse.enabled && !cache?.sessionId) {
await createSession( await createSession({
{ id: sessionId,
id: sessionId, websiteId,
websiteId, browser,
browser, os,
os, device,
device, screen,
screen, language,
language, country,
country, region,
region, city,
city, distinctId: id,
distinctId: id, });
},
{ skipDuplicates: true },
);
} }
// Visit info // Visit info
@ -176,10 +202,19 @@ export async function POST(request: Request) {
} }
} }
const eventType = linkId
? EVENT_TYPE.linkEvent
: pixelId
? EVENT_TYPE.pixelEvent
: name
? EVENT_TYPE.customEvent
: EVENT_TYPE.pageView;
await saveEvent({ await saveEvent({
websiteId, websiteId: websiteId || linkId || pixelId,
sessionId, sessionId,
visitId, visitId,
eventType,
createdAt, createdAt,
// Page // Page
@ -240,6 +275,7 @@ export async function POST(request: Request) {
return json({ cache: token, sessionId, visitId }); return json({ cache: token, sessionId, visitId });
} catch (e) { } catch (e) {
log.error(e);
return serverError(e); return serverError(e);
} }
} }

View file

@ -54,7 +54,7 @@ export async function POST(
return Response.json(website); return Response.json(website);
} catch (e: any) { } catch (e: any) {
if (e.message.includes('Unique constraint') && e.message.includes('share_id')) { if (e.message.toLowerCase().includes('unique constraint') && e.message.includes('share_id')) {
return badRequest('That share ID is already taken.'); return badRequest('That share ID is already taken.');
} }

View file

@ -87,6 +87,8 @@ export const COLLECTION_TYPE = {
export const EVENT_TYPE = { export const EVENT_TYPE = {
pageView: 1, pageView: 1,
customEvent: 2, customEvent: 2,
linkEvent: 3,
pixelEvent: 4,
} as const; } as const;
export const DATA_TYPE = { export const DATA_TYPE = {

View file

@ -89,8 +89,8 @@ function decodeHeader(s: string | undefined | null): string | undefined | null {
export async function getLocation(ip: string = '', headers: Headers, hasPayloadIP: boolean) { export async function getLocation(ip: string = '', headers: Headers, hasPayloadIP: boolean) {
// Ignore local ips // Ignore local ips
if (await isLocalhost(ip)) { if (!ip || (await isLocalhost(ip))) {
return; return null;
} }
if (!hasPayloadIP && !process.env.SKIP_LOCATION_HEADERS) { if (!hasPayloadIP && !process.env.SKIP_LOCATION_HEADERS) {
@ -130,7 +130,7 @@ export async function getLocation(ip: string = '', headers: Headers, hasPayloadI
); );
} }
const result = globalThis[MAXMIND].get(ip?.split(':')[0]); const result = globalThis[MAXMIND]?.get(ip?.split(':')[0]);
if (result) { if (result) {
const country = result.country?.iso_code ?? result?.registered_country?.iso_code; const country = result.country?.iso_code ?? result?.registered_country?.iso_code;

View file

@ -11,8 +11,8 @@ export interface SaveEventArgs {
websiteId: string; websiteId: string;
sessionId: string; sessionId: string;
visitId: string; visitId: string;
eventType: number;
createdAt?: Date; createdAt?: Date;
eventType?: number;
// Page // Page
pageTitle?: string; pageTitle?: string;
@ -115,7 +115,7 @@ async function relationalQuery({
ttclid, ttclid,
lifatid, lifatid,
twclid, twclid,
eventType: eventType || (eventName ? EVENT_TYPE.customEvent : EVENT_TYPE.pageView), eventType,
eventName: eventName ? eventName?.substring(0, EVENT_NAME_LENGTH) : null, eventName: eventName ? eventName?.substring(0, EVENT_NAME_LENGTH) : null,
tag, tag,
hostname, hostname,

View file

@ -1,10 +1,7 @@
import { Prisma } from '@/generated/prisma/client'; import { Prisma } from '@/generated/prisma/client';
import prisma from '@/lib/prisma'; import prisma from '@/lib/prisma';
export async function createSession( export async function createSession(data: Prisma.SessionCreateInput) {
data: Prisma.SessionCreateInput,
options = { skipDuplicates: false },
) {
const { const {
id, id,
websiteId, websiteId,
@ -36,12 +33,7 @@ export async function createSession(
}, },
}); });
} catch (e: any) { } catch (e: any) {
// With skipDuplicates flag: ignore unique constraint error and return null if (e.message.toLowerCase().includes('unique constraint')) {
if (
options.skipDuplicates &&
e instanceof Prisma.PrismaClientKnownRequestError &&
e.code === 'P2002'
) {
return null; return null;
} }
throw e; throw e;