diff --git a/.github/workflows/cd-cloud.yml b/.github/workflows/cd-cloud.yml index 386a6ce0..b155624a 100644 --- a/.github/workflows/cd-cloud.yml +++ b/.github/workflows/cd-cloud.yml @@ -4,6 +4,7 @@ on: push: branches: - analytics + - cloud jobs: build: diff --git a/package.json b/package.json index 5a3546ee..a438a539 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,7 @@ "@react-spring/web": "^9.7.3", "@tanstack/react-query": "^5.28.6", "@umami/prisma-client": "^0.14.0", - "@umami/redis-client": "^0.24.0", + "@umami/redis-client": "^0.25.0", "bcryptjs": "^2.4.3", "chalk": "^4.1.1", "chart.js": "^4.4.2", diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 0b48fe83..7ae22e2b 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import { checkPassword } from '@/lib/auth'; import { createSecureToken } from '@/lib/jwt'; -import { redisEnabled } from '@umami/redis-client'; +import redis from '@/lib/redis'; import { getUserByUsername } from '@/queries'; import { json, unauthorized } from '@/lib/response'; import { parseRequest } from '@/lib/request'; @@ -29,15 +29,16 @@ export async function POST(request: Request) { 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; + let token: string; + + if (redis.enabled) { + token = await saveAuth({ userId: id, role }); + } else { + token = createSecureToken({ userId: user.id, role }, secret()); + } + return json({ token, user: { id, username, role, createdAt, isAdmin: role === ROLES.admin }, diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts index 22bb3091..7bf0a813 100644 --- a/src/app/api/auth/logout/route.ts +++ b/src/app/api/auth/logout/route.ts @@ -1,13 +1,11 @@ -import { getClient, redisEnabled } from '@umami/redis-client'; +import redis from '@/lib/redis'; import { ok } from '@/lib/response'; export async function POST(request: Request) { - if (redisEnabled) { - const redis = getClient(); - + if (redis.enabled) { const token = request.headers.get('authorization')?.split(' ')?.[1]; - await redis.del(token); + await redis.client.del(token); } return ok(); diff --git a/src/app/api/auth/sso/route.ts b/src/app/api/auth/sso/route.ts index 4a713424..fc8fb9bf 100644 --- a/src/app/api/auth/sso/route.ts +++ b/src/app/api/auth/sso/route.ts @@ -1,4 +1,4 @@ -import { redisEnabled } from '@umami/redis-client'; +import redis from '@/lib/redis'; import { json } from '@/lib/response'; import { parseRequest } from '@/lib/request'; import { saveAuth } from '@/lib/auth'; @@ -10,7 +10,7 @@ export async function POST(request: Request) { return error(); } - if (redisEnabled) { + if (redis.enabled) { const token = await saveAuth({ userId: auth.user.id }, 86400); return json({ user: auth.user, token }); diff --git a/src/app/api/send/route.ts b/src/app/api/send/route.ts index 2892eefd..b70f5d19 100644 --- a/src/app/api/send/route.ts +++ b/src/app/api/send/route.ts @@ -9,190 +9,195 @@ import { getClientInfo, hasBlockedIp } from '@/lib/detect'; import { secret, uuid, visitSalt } from '@/lib/crypto'; import { COLLECTION_TYPE } from '@/lib/constants'; 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) { - // Bot check - 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; + try { + // Bot check + if (!process.env.DISABLE_BOT_CHECK && isbot(request.headers.get('user-agent'))) { + return json({ beep: 'boop' }); } - } - // Find website - if (!cache?.websiteId) { - const website = await fetchWebsite(websiteId); + const { body, error } = await parseRequest(request, schema, { skipAuth: true }); - if (!website) { - return badRequest('Website not found.'); + if (error) { + return error(); } - } - // Client info - const { ip, userAgent, device, browser, os, country, subdivision1, subdivision2, city } = - await getClientInfo(request, payload); + const { type, payload } = body; - // IP block - if (hasBlockedIp(ip)) { - return forbidden(); - } + const { + website: websiteId, + 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 (!cache?.sessionId) { - const session = await fetchSession(websiteId, sessionId); + if (cacheHeader) { + const result = await parseToken(cacheHeader, secret()); - // Create a session if not found - if (!session && !clickhouse.enabled) { - 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); + if (result) { + cache = result; + } + } + + // Find website + if (!cache?.websiteId) { + const website = await fetchWebsite(websiteId); + + if (!website) { + return badRequest('Website not found.'); + } + } + + // Client info + const { ip, userAgent, device, browser, os, country, subdivision1, subdivision2, city } = + await getClientInfo(request, payload); + + // IP block + 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 - const now = Math.floor(new Date().getTime() / 1000); - let visitId = cache?.visitId || uuid(sessionId, visitSalt()); - let iat = cache?.iat || now; + // Visit info + const now = Math.floor(new Date().getTime() / 1000); + let visitId = cache?.visitId || uuid(sessionId, visitSalt()); + let iat = cache?.iat || now; - // Expire visit after 30 minutes - if (now - iat > 1800) { - visitId = uuid(sessionId, visitSalt()); - 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'); + // Expire visit after 30 minutes + if (now - iat > 1800) { + visitId = uuid(sessionId, visitSalt()); + iat = now; } - let referrerPath: string; - let referrerQuery: string; - let referrerDomain: string; + if (type === COLLECTION_TYPE.event) { + const base = hostname ? `https://${hostname}` : 'https://localhost'; + const currentUrl = new URL(url, base); - if (referrer) { - const referrerUrl = new URL(referrer, base); + let urlPath = currentUrl.pathname; + const urlQuery = currentUrl.search.substring(1); + const urlDomain = currentUrl.hostname.replace(/^www./, ''); - referrerPath = referrerUrl.pathname; - referrerQuery = referrerUrl.search.substring(1); - - if (referrerUrl.hostname !== 'localhost') { - referrerDomain = referrerUrl.hostname.replace(/^www\./, ''); + if (process.env.REMOVE_TRAILING_SLASH) { + urlPath = urlPath.replace(/(.+)\/$/, '$1'); } + + 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({ - 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, - }); - } + if (type === COLLECTION_TYPE.identify) { + if (!data) { + return badRequest('Data required.'); + } - if (type === COLLECTION_TYPE.identify) { - if (!data) { - return badRequest('Data required.'); + await saveSessionData({ + websiteId, + sessionId, + sessionData: data, + }); } - await saveSessionData({ - websiteId, - sessionId, - sessionData: data, - }); + const token = createToken({ websiteId, sessionId, visitId, iat }, secret()); + + return json({ cache: token }); + } catch (e) { + return serverError(e); } - - const token = createToken({ websiteId, sessionId, visitId, iat }, secret()); - - return json({ cache: token }); } diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 4ce90706..d67566b8 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,6 +1,6 @@ import bcrypt from 'bcryptjs'; import { Report } from '@prisma/client'; -import { getClient, redisEnabled } from '@umami/redis-client'; +import redis from '@/lib/redis'; import debug from 'debug'; import { PERMISSIONS, ROLE_PERMISSIONS, ROLES, SHARE_TOKEN_HEADER } from '@/lib/constants'; import { secret, getRandomChars } from '@/lib/crypto'; @@ -31,10 +31,8 @@ export async function checkAuth(request: Request) { if (userId) { user = await getUser(userId); - } else if (redisEnabled && authKey) { - const redis = getClient(); - - const key = await redis.get(authKey); + } else if (redis.enabled && authKey) { + const key = await redis.client.get(authKey); if (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) { 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.expire(authKey, expire); + if (expire) { + await redis.client.expire(authKey, expire); + } } return createSecureToken({ authKey }, secret()); diff --git a/src/lib/load.ts b/src/lib/load.ts index 2b7f7de4..d9aa23c2 100644 --- a/src/lib/load.ts +++ b/src/lib/load.ts @@ -1,14 +1,12 @@ import { Website, Session } from '@prisma/client'; -import { getClient, redisEnabled } from '@umami/redis-client'; +import redis from '@/lib/redis'; import { getWebsiteSession, getWebsite } from '@/queries'; export async function fetchWebsite(websiteId: string): Promise { let website = null; - if (redisEnabled) { - const redis = getClient(); - - website = await redis.fetch(`website:${websiteId}`, () => getWebsite(websiteId), 86400); + if (redis.enabled) { + website = await redis.client.fetch(`website:${websiteId}`, () => getWebsite(websiteId), 86400); } else { website = await getWebsite(websiteId); } @@ -23,10 +21,8 @@ export async function fetchWebsite(websiteId: string): Promise { export async function fetchSession(websiteId: string, sessionId: string): Promise { let session = null; - if (redisEnabled) { - const redis = getClient(); - - session = await redis.fetch( + if (redis.enabled) { + session = await redis.client.fetch( `session:${sessionId}`, () => getWebsiteSession(websiteId, sessionId), 86400, diff --git a/src/lib/redis.ts b/src/lib/redis.ts new file mode 100644 index 00000000..868b408a --- /dev/null +++ b/src/lib/redis.ts @@ -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 }; diff --git a/src/lib/request.ts b/src/lib/request.ts index 63688f02..9d32f89b 100644 --- a/src/lib/request.ts +++ b/src/lib/request.ts @@ -7,7 +7,7 @@ import { getWebsiteDateRange } from '@/queries'; export async function getJsonBody(request: Request) { try { - return request.clone().json(); + return await request.clone().json(); } catch { return undefined; } diff --git a/src/lib/schema.ts b/src/lib/schema.ts index 84662f04..9fca4b8a 100644 --- a/src/lib/schema.ts +++ b/src/lib/schema.ts @@ -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 urlOrPathParam = z.string().refine( + value => { + try { + new URL(value, 'https://localhost'); + return true; + } catch { + return false; + } + }, + { + message: 'Invalid URL.', + }, +); + export const reportTypeParam = z.enum([ 'funnel', 'insights', diff --git a/src/queries/prisma/website.ts b/src/queries/prisma/website.ts index 96463501..25328914 100644 --- a/src/queries/prisma/website.ts +++ b/src/queries/prisma/website.ts @@ -1,5 +1,5 @@ import { Prisma, Website } from '@prisma/client'; -import { getClient } from '@umami/redis-client'; +import redis from '@/lib/redis'; import prisma from '@/lib/prisma'; import { PageResult, PageParams } from '@/lib/types'; import WebsiteFindManyArgs = Prisma.WebsiteFindManyArgs; @@ -182,9 +182,7 @@ export async function resetWebsite( }), ]).then(async data => { if (cloudMode) { - const redis = getClient(); - - await redis.set(`website:${websiteId}`, data[3]); + await redis.client.set(`website:${websiteId}`, data[3]); } return data; @@ -227,9 +225,7 @@ export async function deleteWebsite( }), ]).then(async data => { if (cloudMode) { - const redis = getClient(); - - await redis.del(`website:${websiteId}`); + await redis.client.del(`website:${websiteId}`); } return data; diff --git a/yarn.lock b/yarn.lock index df5503d9..325aa2aa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3402,10 +3402,10 @@ chalk "^4.1.2" debug "^4.3.4" -"@umami/redis-client@^0.24.0": - version "0.24.0" - resolved "https://registry.yarnpkg.com/@umami/redis-client/-/redis-client-0.24.0.tgz#8af489250396be76bc0906766343620589774c4b" - integrity sha512-yUZmC87H5QZKNA6jD9k/7d8WDaXQTDROlpyK7S+V2csD96eAnMNi7JsWAVWx9T/584QKD8DsSIy87PTWq1HNPw== +"@umami/redis-client@^0.25.0": + version "0.25.0" + resolved "https://registry.yarnpkg.com/@umami/redis-client/-/redis-client-0.25.0.tgz#8bf01f22ceb3b90e15e59ab8daf44f838b83a6a7" + integrity sha512-j2GUehtrUfNPuikmcVXucgnL04gQOtbLiG20NqdlUXlDA/ebkV/waDfcYtMLuvXOFwiEeTatqPFEfXYuLDwJWw== dependencies: debug "^4.3.4" redis "^4.5.1"