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);
|
||||
} 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.');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue