mirror of
https://github.com/umami-software/umami.git
synced 2026-02-04 04:37:11 +01:00
Pixel route.
This commit is contained in:
parent
3c5c1e48e9
commit
0ac8bd41b6
10 changed files with 139 additions and 64 deletions
45
src/app/(collect)/p/[slug]/route.ts
Normal file
45
src/app/(collect)/p/[slug]/route.ts
Normal 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),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
0
src/app/(collect)/q/[slug]/route.ts
Normal file
0
src/app/(collect)/q/[slug]/route.ts
Normal 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.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue