diff --git a/src/app/(collect)/p/[slug]/route.ts b/src/app/(collect)/p/[slug]/route.ts new file mode 100644 index 00000000..97d9a3f2 --- /dev/null +++ b/src/app/(collect)/p/[slug]/route.ts @@ -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), + }, + }); +} diff --git a/src/app/(collect)/q/[slug]/route.ts b/src/app/(collect)/q/[slug]/route.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/app/api/links/[linkId]/route.ts b/src/app/api/links/[linkId]/route.ts index db50e878..9647961c 100644 --- a/src/app/api/links/[linkId]/route.ts +++ b/src/app/api/links/[linkId]/route.ts @@ -47,7 +47,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ lin return Response.json(result); } 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.'); } diff --git a/src/app/api/pixels/[pixelId]/route.ts b/src/app/api/pixels/[pixelId]/route.ts index f9c95236..7a4bcfda 100644 --- a/src/app/api/pixels/[pixelId]/route.ts +++ b/src/app/api/pixels/[pixelId]/route.ts @@ -46,7 +46,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ pix return Response.json(pixel); } 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.'); } diff --git a/src/app/api/send/route.ts b/src/app/api/send/route.ts index 04876cd4..0b177f08 100644 --- a/src/app/api/send/route.ts +++ b/src/app/api/send/route.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import debug from 'debug'; import { isbot } from 'isbot'; import { startOfHour, startOfMonth } from 'date-fns'; import clickhouse from '@/lib/clickhouse'; @@ -8,29 +9,52 @@ 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 } from '@/lib/constants'; +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'; +const log = debug('umami:send'); + +interface Cache { + websiteId: string; + sessionId: string; + visitId: string; + iat: number; +} + const schema = z.object({ type: z.enum(['event', 'identify']), - payload: z.object({ - website: z.string().uuid(), - 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().ip().optional(), - userAgent: z.string().optional(), - timestamp: z.coerce.number().int().optional(), - id: z.string().optional(), - }), + payload: z + .object({ + website: z.string().uuid().optional(), + link: z.string().uuid().optional(), + pixel: z.string().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().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) { @@ -45,6 +69,8 @@ export async function POST(request: Request) { const { website: websiteId, + pixel: pixelId, + link: linkId, hostname, screen, language, @@ -59,23 +85,26 @@ export async function POST(request: Request) { } = payload; // Cache check - let cache: { websiteId: string; sessionId: string; visitId: string; iat: number } | null = null; - const cacheHeader = request.headers.get('x-umami-cache'); + let cache: Cache | null = null; - if (cacheHeader) { - const result = await parseToken(cacheHeader, secret()); + if (websiteId) { + const cacheHeader = request.headers.get('x-umami-cache'); - if (result) { - cache = result; + if (cacheHeader) { + const result = await parseToken(cacheHeader, secret()); + + if (result) { + cache = result; + } } - } - // Find website - if (!cache?.websiteId) { - const website = await fetchWebsite(websiteId); + // Find website + if (!cache?.websiteId) { + const website = await fetchWebsite(websiteId); - if (!website) { - return badRequest('Website not found.'); + if (!website) { + return badRequest('Website not found.'); + } } } @@ -105,22 +134,19 @@ export async function POST(request: Request) { // Create a session if not found if (!clickhouse.enabled && !cache?.sessionId) { - await createSession( - { - id: sessionId, - websiteId, - browser, - os, - device, - screen, - language, - country, - region, - city, - distinctId: id, - }, - { skipDuplicates: true }, - ); + await createSession({ + id: sessionId, + websiteId, + browser, + os, + device, + screen, + language, + country, + region, + city, + distinctId: id, + }); } // 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({ - websiteId, + websiteId: websiteId || linkId || pixelId, sessionId, visitId, + eventType, createdAt, // Page @@ -240,6 +275,7 @@ export async function POST(request: Request) { return json({ cache: token, sessionId, visitId }); } catch (e) { + log.error(e); return serverError(e); } } diff --git a/src/app/api/websites/[websiteId]/route.ts b/src/app/api/websites/[websiteId]/route.ts index 22f3979d..466711ad 100644 --- a/src/app/api/websites/[websiteId]/route.ts +++ b/src/app/api/websites/[websiteId]/route.ts @@ -54,7 +54,7 @@ export async function POST( return Response.json(website); } 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.'); } diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 168d9304..759690b6 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -87,6 +87,8 @@ export const COLLECTION_TYPE = { export const EVENT_TYPE = { pageView: 1, customEvent: 2, + linkEvent: 3, + pixelEvent: 4, } as const; export const DATA_TYPE = { diff --git a/src/lib/detect.ts b/src/lib/detect.ts index f2c9d9fb..e15e5b16 100644 --- a/src/lib/detect.ts +++ b/src/lib/detect.ts @@ -89,8 +89,8 @@ function decodeHeader(s: string | undefined | null): string | undefined | null { export async function getLocation(ip: string = '', headers: Headers, hasPayloadIP: boolean) { // Ignore local ips - if (await isLocalhost(ip)) { - return; + if (!ip || (await isLocalhost(ip))) { + return null; } 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) { const country = result.country?.iso_code ?? result?.registered_country?.iso_code; diff --git a/src/queries/sql/events/saveEvent.ts b/src/queries/sql/events/saveEvent.ts index c351b95c..06376740 100644 --- a/src/queries/sql/events/saveEvent.ts +++ b/src/queries/sql/events/saveEvent.ts @@ -11,8 +11,8 @@ export interface SaveEventArgs { websiteId: string; sessionId: string; visitId: string; + eventType: number; createdAt?: Date; - eventType?: number; // Page pageTitle?: string; @@ -115,7 +115,7 @@ async function relationalQuery({ ttclid, lifatid, twclid, - eventType: eventType || (eventName ? EVENT_TYPE.customEvent : EVENT_TYPE.pageView), + eventType, eventName: eventName ? eventName?.substring(0, EVENT_NAME_LENGTH) : null, tag, hostname, diff --git a/src/queries/sql/sessions/createSession.ts b/src/queries/sql/sessions/createSession.ts index 395f9f4c..958754f1 100644 --- a/src/queries/sql/sessions/createSession.ts +++ b/src/queries/sql/sessions/createSession.ts @@ -1,10 +1,7 @@ import { Prisma } from '@/generated/prisma/client'; import prisma from '@/lib/prisma'; -export async function createSession( - data: Prisma.SessionCreateInput, - options = { skipDuplicates: false }, -) { +export async function createSession(data: Prisma.SessionCreateInput) { const { id, websiteId, @@ -36,12 +33,7 @@ export async function createSession( }, }); } catch (e: any) { - // With skipDuplicates flag: ignore unique constraint error and return null - if ( - options.skipDuplicates && - e instanceof Prisma.PrismaClientKnownRequestError && - e.code === 'P2002' - ) { + if (e.message.toLowerCase().includes('unique constraint')) { return null; } throw e;