Merge branch 'dev' into analytics

This commit is contained in:
Mike Cao 2025-02-10 22:05:36 -08:00
commit f4f09900e9
13 changed files with 232 additions and 206 deletions

View file

@ -4,6 +4,7 @@ on:
push: push:
branches: branches:
- analytics - analytics
- cloud
jobs: jobs:
build: build:

View file

@ -75,7 +75,7 @@
"@react-spring/web": "^9.7.3", "@react-spring/web": "^9.7.3",
"@tanstack/react-query": "^5.28.6", "@tanstack/react-query": "^5.28.6",
"@umami/prisma-client": "^0.14.0", "@umami/prisma-client": "^0.14.0",
"@umami/redis-client": "^0.24.0", "@umami/redis-client": "^0.25.0",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"chalk": "^4.1.1", "chalk": "^4.1.1",
"chart.js": "^4.4.2", "chart.js": "^4.4.2",

View file

@ -1,7 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import { checkPassword } from '@/lib/auth'; import { checkPassword } from '@/lib/auth';
import { createSecureToken } from '@/lib/jwt'; import { createSecureToken } from '@/lib/jwt';
import { redisEnabled } from '@umami/redis-client'; import redis from '@/lib/redis';
import { getUserByUsername } from '@/queries'; import { getUserByUsername } from '@/queries';
import { json, unauthorized } from '@/lib/response'; import { json, unauthorized } from '@/lib/response';
import { parseRequest } from '@/lib/request'; import { parseRequest } from '@/lib/request';
@ -29,15 +29,16 @@ export async function POST(request: Request) {
return unauthorized(); return unauthorized();
} }
if (redisEnabled) {
const token = await saveAuth({ userId: user.id });
return json({ token, user });
}
const token = createSecureToken({ userId: user.id }, secret());
const { id, role, createdAt } = user; const { id, role, createdAt } = user;
let token: string;
if (redis.enabled) {
token = await saveAuth({ userId: id, role });
} else {
token = createSecureToken({ userId: user.id, role }, secret());
}
return json({ return json({
token, token,
user: { id, username, role, createdAt, isAdmin: role === ROLES.admin }, user: { id, username, role, createdAt, isAdmin: role === ROLES.admin },

View file

@ -1,13 +1,11 @@
import { getClient, redisEnabled } from '@umami/redis-client'; import redis from '@/lib/redis';
import { ok } from '@/lib/response'; import { ok } from '@/lib/response';
export async function POST(request: Request) { export async function POST(request: Request) {
if (redisEnabled) { if (redis.enabled) {
const redis = getClient();
const token = request.headers.get('authorization')?.split(' ')?.[1]; const token = request.headers.get('authorization')?.split(' ')?.[1];
await redis.del(token); await redis.client.del(token);
} }
return ok(); return ok();

View file

@ -1,4 +1,4 @@
import { redisEnabled } from '@umami/redis-client'; import redis from '@/lib/redis';
import { json } from '@/lib/response'; import { json } from '@/lib/response';
import { parseRequest } from '@/lib/request'; import { parseRequest } from '@/lib/request';
import { saveAuth } from '@/lib/auth'; import { saveAuth } from '@/lib/auth';
@ -10,7 +10,7 @@ export async function POST(request: Request) {
return error(); return error();
} }
if (redisEnabled) { if (redis.enabled) {
const token = await saveAuth({ userId: auth.user.id }, 86400); const token = await saveAuth({ userId: auth.user.id }, 86400);
return json({ user: auth.user, token }); return json({ user: auth.user, token });

View file

@ -9,190 +9,195 @@ import { getClientInfo, hasBlockedIp } from '@/lib/detect';
import { secret, uuid, visitSalt } from '@/lib/crypto'; import { secret, uuid, visitSalt } from '@/lib/crypto';
import { COLLECTION_TYPE } from '@/lib/constants'; import { COLLECTION_TYPE } from '@/lib/constants';
import { createSession, saveEvent, saveSessionData } from '@/queries'; import { createSession, saveEvent, saveSessionData } from '@/queries';
import { urlOrPathParam } from '@/lib/schema';
const schema = z.object({
type: z.enum(['event', 'identify']),
payload: z.object({
website: z.string().uuid(),
data: z.object({}).passthrough().optional(),
hostname: z.string().max(100).optional(),
language: z.string().max(35).optional(),
referrer: urlOrPathParam,
screen: z.string().max(11).optional(),
title: z.string().optional(),
url: urlOrPathParam,
name: z.string().url().max(50).optional(),
tag: z.string().max(50).optional(),
ip: z.string().ip().optional(),
userAgent: z.string().optional(),
}),
});
export async function POST(request: Request) { export async function POST(request: Request) {
// Bot check try {
if (!process.env.DISABLE_BOT_CHECK && isbot(request.headers.get('user-agent'))) { // Bot check
return json({ beep: 'boop' }); if (!process.env.DISABLE_BOT_CHECK && isbot(request.headers.get('user-agent'))) {
} return json({ beep: 'boop' });
const schema = z.object({
type: z.enum(['event', 'identify']),
payload: z.object({
website: z.string().uuid(),
data: z.object({}).passthrough().optional(),
hostname: z.string().max(100).optional(),
language: z.string().max(35).optional(),
referrer: z.string().optional(),
screen: z.string().max(11).optional(),
title: z.string().optional(),
url: z.string().optional(),
name: z.string().max(50).optional(),
tag: z.string().max(50).optional(),
ip: z.string().ip().optional(),
userAgent: z.string().optional(),
}),
});
const { body, error } = await parseRequest(request, schema, { skipAuth: true });
if (error) {
return error();
}
const { type, payload } = body;
const {
website: websiteId,
hostname,
screen,
language,
url,
referrer,
name,
data,
title,
tag,
} = payload;
// Cache check
let cache: { websiteId: string; sessionId: string; visitId: string; iat: number } | null = null;
const cacheHeader = request.headers.get('x-umami-cache');
if (cacheHeader) {
const result = await parseToken(cacheHeader, secret());
if (result) {
cache = result;
} }
}
// Find website const { body, error } = await parseRequest(request, schema, { skipAuth: true });
if (!cache?.websiteId) {
const website = await fetchWebsite(websiteId);
if (!website) { if (error) {
return badRequest('Website not found.'); return error();
} }
}
// Client info const { type, payload } = body;
const { ip, userAgent, device, browser, os, country, subdivision1, subdivision2, city } =
await getClientInfo(request, payload);
// IP block const {
if (hasBlockedIp(ip)) { website: websiteId,
return forbidden(); hostname,
} screen,
language,
url,
referrer,
name,
data,
title,
tag,
} = payload;
const sessionId = uuid(websiteId, hostname, ip, userAgent); // Cache check
let cache: { websiteId: string; sessionId: string; visitId: string; iat: number } | null = null;
const cacheHeader = request.headers.get('x-umami-cache');
// Find session if (cacheHeader) {
if (!cache?.sessionId) { const result = await parseToken(cacheHeader, secret());
const session = await fetchSession(websiteId, sessionId);
// Create a session if not found if (result) {
if (!session && !clickhouse.enabled) { cache = result;
try { }
await createSession({ }
id: sessionId,
websiteId, // Find website
hostname, if (!cache?.websiteId) {
browser, const website = await fetchWebsite(websiteId);
os,
device, if (!website) {
screen, return badRequest('Website not found.');
language, }
country, }
subdivision1,
subdivision2, // Client info
city, const { ip, userAgent, device, browser, os, country, subdivision1, subdivision2, city } =
}); await getClientInfo(request, payload);
} catch (e: any) {
if (!e.message.toLowerCase().includes('unique constraint')) { // IP block
return serverError(e); if (hasBlockedIp(ip)) {
return forbidden();
}
const sessionId = uuid(websiteId, hostname, ip, userAgent);
// Find session
if (!clickhouse.enabled && !cache?.sessionId) {
const session = await fetchSession(websiteId, sessionId);
// Create a session if not found
if (!session) {
try {
await createSession({
id: sessionId,
websiteId,
hostname,
browser,
os,
device,
screen,
language,
country,
subdivision1,
subdivision2,
city,
});
} catch (e: any) {
if (!e.message.toLowerCase().includes('unique constraint')) {
return serverError(e);
}
} }
} }
} }
}
// Visit info // Visit info
const now = Math.floor(new Date().getTime() / 1000); const now = Math.floor(new Date().getTime() / 1000);
let visitId = cache?.visitId || uuid(sessionId, visitSalt()); let visitId = cache?.visitId || uuid(sessionId, visitSalt());
let iat = cache?.iat || now; let iat = cache?.iat || now;
// Expire visit after 30 minutes // Expire visit after 30 minutes
if (now - iat > 1800) { if (now - iat > 1800) {
visitId = uuid(sessionId, visitSalt()); visitId = uuid(sessionId, visitSalt());
iat = now; iat = now;
}
if (type === COLLECTION_TYPE.event) {
const base = hostname ? `http://${hostname}` : 'http://localhost';
const currentUrl = new URL(url, base);
let urlPath = currentUrl.pathname;
const urlQuery = currentUrl.search.substring(1);
const urlDomain = currentUrl.hostname.replace(/^www\./, '');
if (process.env.REMOVE_TRAILING_SLASH) {
urlPath = urlPath.replace(/(.+)\/$/, '$1');
} }
let referrerPath: string; if (type === COLLECTION_TYPE.event) {
let referrerQuery: string; const base = hostname ? `https://${hostname}` : 'https://localhost';
let referrerDomain: string; const currentUrl = new URL(url, base);
if (referrer) { let urlPath = currentUrl.pathname;
const referrerUrl = new URL(referrer, base); const urlQuery = currentUrl.search.substring(1);
const urlDomain = currentUrl.hostname.replace(/^www./, '');
referrerPath = referrerUrl.pathname; if (process.env.REMOVE_TRAILING_SLASH) {
referrerQuery = referrerUrl.search.substring(1); urlPath = urlPath.replace(/(.+)\/$/, '$1');
if (referrerUrl.hostname !== 'localhost') {
referrerDomain = referrerUrl.hostname.replace(/^www\./, '');
} }
let referrerPath: string;
let referrerQuery: string;
let referrerDomain: string;
if (referrer) {
const referrerUrl = new URL(referrer, base);
referrerPath = referrerUrl.pathname;
referrerQuery = referrerUrl.search.substring(1);
if (referrerUrl.hostname !== 'localhost') {
referrerDomain = referrerUrl.hostname.replace(/^www\./, '');
}
}
await saveEvent({
websiteId,
sessionId,
visitId,
urlPath,
urlQuery,
referrerPath,
referrerQuery,
referrerDomain,
pageTitle: title,
eventName: name,
eventData: data,
hostname: hostname || urlDomain,
browser,
os,
device,
screen,
language,
country,
subdivision1,
subdivision2,
city,
tag,
});
} }
await saveEvent({ if (type === COLLECTION_TYPE.identify) {
websiteId, if (!data) {
sessionId, return badRequest('Data required.');
visitId, }
urlPath,
urlQuery,
referrerPath,
referrerQuery,
referrerDomain,
pageTitle: title,
eventName: name,
eventData: data,
hostname: hostname || urlDomain,
browser,
os,
device,
screen,
language,
country,
subdivision1,
subdivision2,
city,
tag,
});
}
if (type === COLLECTION_TYPE.identify) { await saveSessionData({
if (!data) { websiteId,
return badRequest('Data required.'); sessionId,
sessionData: data,
});
} }
await saveSessionData({ const token = createToken({ websiteId, sessionId, visitId, iat }, secret());
websiteId,
sessionId, return json({ cache: token });
sessionData: data, } catch (e) {
}); return serverError(e);
} }
const token = createToken({ websiteId, sessionId, visitId, iat }, secret());
return json({ cache: token });
} }

View file

@ -1,6 +1,6 @@
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { Report } from '@prisma/client'; import { Report } from '@prisma/client';
import { getClient, redisEnabled } from '@umami/redis-client'; import redis from '@/lib/redis';
import debug from 'debug'; import debug from 'debug';
import { PERMISSIONS, ROLE_PERMISSIONS, ROLES, SHARE_TOKEN_HEADER } from '@/lib/constants'; import { PERMISSIONS, ROLE_PERMISSIONS, ROLES, SHARE_TOKEN_HEADER } from '@/lib/constants';
import { secret, getRandomChars } from '@/lib/crypto'; import { secret, getRandomChars } from '@/lib/crypto';
@ -31,10 +31,8 @@ export async function checkAuth(request: Request) {
if (userId) { if (userId) {
user = await getUser(userId); user = await getUser(userId);
} else if (redisEnabled && authKey) { } else if (redis.enabled && authKey) {
const redis = getClient(); const key = await redis.client.get(authKey);
const key = await redis.get(authKey);
if (key?.userId) { if (key?.userId) {
user = await getUser(key.userId); user = await getUser(key.userId);
@ -66,12 +64,12 @@ export async function checkAuth(request: Request) {
export async function saveAuth(data: any, expire = 0) { export async function saveAuth(data: any, expire = 0) {
const authKey = `auth:${getRandomChars(32)}`; const authKey = `auth:${getRandomChars(32)}`;
const redis = getClient(); if (redis.enabled) {
await redis.client.set(authKey, data);
await redis.set(authKey, data); if (expire) {
await redis.client.expire(authKey, expire);
if (expire) { }
await redis.expire(authKey, expire);
} }
return createSecureToken({ authKey }, secret()); return createSecureToken({ authKey }, secret());

View file

@ -1,14 +1,12 @@
import { Website, Session } from '@prisma/client'; import { Website, Session } from '@prisma/client';
import { getClient, redisEnabled } from '@umami/redis-client'; import redis from '@/lib/redis';
import { getWebsiteSession, getWebsite } from '@/queries'; import { getWebsiteSession, getWebsite } from '@/queries';
export async function fetchWebsite(websiteId: string): Promise<Website> { export async function fetchWebsite(websiteId: string): Promise<Website> {
let website = null; let website = null;
if (redisEnabled) { if (redis.enabled) {
const redis = getClient(); website = await redis.client.fetch(`website:${websiteId}`, () => getWebsite(websiteId), 86400);
website = await redis.fetch(`website:${websiteId}`, () => getWebsite(websiteId), 86400);
} else { } else {
website = await getWebsite(websiteId); website = await getWebsite(websiteId);
} }
@ -23,10 +21,8 @@ export async function fetchWebsite(websiteId: string): Promise<Website> {
export async function fetchSession(websiteId: string, sessionId: string): Promise<Session> { export async function fetchSession(websiteId: string, sessionId: string): Promise<Session> {
let session = null; let session = null;
if (redisEnabled) { if (redis.enabled) {
const redis = getClient(); session = await redis.client.fetch(
session = await redis.fetch(
`session:${sessionId}`, `session:${sessionId}`,
() => getWebsiteSession(websiteId, sessionId), () => getWebsiteSession(websiteId, sessionId),
86400, 86400,

17
src/lib/redis.ts Normal file
View file

@ -0,0 +1,17 @@
import { REDIS, UmamiRedisClient } from '@umami/redis-client';
const enabled = !!process.env.REDIS_URL;
function getClient() {
const client = new UmamiRedisClient(process.env.REDIS_URL);
if (process.env.NODE_ENV !== 'production') {
global[REDIS] = client;
}
return client;
}
const client = global[REDIS] || getClient();
export default { client, enabled };

View file

@ -7,7 +7,7 @@ import { getWebsiteDateRange } from '@/queries';
export async function getJsonBody(request: Request) { export async function getJsonBody(request: Request) {
try { try {
return request.clone().json(); return await request.clone().json();
} catch { } catch {
return undefined; return undefined;
} }

View file

@ -36,6 +36,20 @@ export const unitParam = z.string().refine(value => UNIT_TYPES.includes(value),
export const roleParam = z.enum(['team-member', 'team-view-only', 'team-manager']); export const roleParam = z.enum(['team-member', 'team-view-only', 'team-manager']);
export const urlOrPathParam = z.string().refine(
value => {
try {
new URL(value, 'https://localhost');
return true;
} catch {
return false;
}
},
{
message: 'Invalid URL.',
},
);
export const reportTypeParam = z.enum([ export const reportTypeParam = z.enum([
'funnel', 'funnel',
'insights', 'insights',

View file

@ -1,5 +1,5 @@
import { Prisma, Website } from '@prisma/client'; import { Prisma, Website } from '@prisma/client';
import { getClient } from '@umami/redis-client'; import redis from '@/lib/redis';
import prisma from '@/lib/prisma'; import prisma from '@/lib/prisma';
import { PageResult, PageParams } from '@/lib/types'; import { PageResult, PageParams } from '@/lib/types';
import WebsiteFindManyArgs = Prisma.WebsiteFindManyArgs; import WebsiteFindManyArgs = Prisma.WebsiteFindManyArgs;
@ -182,9 +182,7 @@ export async function resetWebsite(
}), }),
]).then(async data => { ]).then(async data => {
if (cloudMode) { if (cloudMode) {
const redis = getClient(); await redis.client.set(`website:${websiteId}`, data[3]);
await redis.set(`website:${websiteId}`, data[3]);
} }
return data; return data;
@ -227,9 +225,7 @@ export async function deleteWebsite(
}), }),
]).then(async data => { ]).then(async data => {
if (cloudMode) { if (cloudMode) {
const redis = getClient(); await redis.client.del(`website:${websiteId}`);
await redis.del(`website:${websiteId}`);
} }
return data; return data;

View file

@ -3402,10 +3402,10 @@
chalk "^4.1.2" chalk "^4.1.2"
debug "^4.3.4" debug "^4.3.4"
"@umami/redis-client@^0.24.0": "@umami/redis-client@^0.25.0":
version "0.24.0" version "0.25.0"
resolved "https://registry.yarnpkg.com/@umami/redis-client/-/redis-client-0.24.0.tgz#8af489250396be76bc0906766343620589774c4b" resolved "https://registry.yarnpkg.com/@umami/redis-client/-/redis-client-0.25.0.tgz#8bf01f22ceb3b90e15e59ab8daf44f838b83a6a7"
integrity sha512-yUZmC87H5QZKNA6jD9k/7d8WDaXQTDROlpyK7S+V2csD96eAnMNi7JsWAVWx9T/584QKD8DsSIy87PTWq1HNPw== integrity sha512-j2GUehtrUfNPuikmcVXucgnL04gQOtbLiG20NqdlUXlDA/ebkV/waDfcYtMLuvXOFwiEeTatqPFEfXYuLDwJWw==
dependencies: dependencies:
debug "^4.3.4" debug "^4.3.4"
redis "^4.5.1" redis "^4.5.1"