From 16a2abaf21147b5bcceb8648cc26089e158b976e Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Mon, 10 Feb 2025 18:21:30 -0800 Subject: [PATCH 1/6] Don't remove www from hostname. --- src/app/api/send/route.ts | 40 +++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/src/app/api/send/route.ts b/src/app/api/send/route.ts index 2892eefd..21e8790e 100644 --- a/src/app/api/send/route.ts +++ b/src/app/api/send/route.ts @@ -10,30 +10,30 @@ import { secret, uuid, visitSalt } from '@/lib/crypto'; import { COLLECTION_TYPE } from '@/lib/constants'; import { createSession, saveEvent, saveSessionData } from '@/queries'; +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(), + }), +}); + 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) { @@ -133,7 +133,7 @@ export async function POST(request: Request) { let urlPath = currentUrl.pathname; const urlQuery = currentUrl.search.substring(1); - const urlDomain = currentUrl.hostname.replace(/^www\./, ''); + const urlDomain = currentUrl.hostname; if (process.env.REMOVE_TRAILING_SLASH) { urlPath = urlPath.replace(/(.+)\/$/, '$1'); @@ -150,7 +150,7 @@ export async function POST(request: Request) { referrerQuery = referrerUrl.search.substring(1); if (referrerUrl.hostname !== 'localhost') { - referrerDomain = referrerUrl.hostname.replace(/^www\./, ''); + referrerDomain = referrerUrl.hostname; } } From e5334ffa033d54239cbf3ba9bfd5a249d9dddce9 Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Mon, 10 Feb 2025 19:30:02 -0800 Subject: [PATCH 2/6] Added cloud branch build. --- .github/workflows/cd-cloud.yml | 1 + 1 file changed, 1 insertion(+) 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: From 39e7ceac065252d6d67fd559f764849f23cae532 Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Mon, 10 Feb 2025 20:07:12 -0800 Subject: [PATCH 3/6] Ignore session check for clickhouse. --- src/app/api/send/route.ts | 8 ++++---- src/lib/request.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/app/api/send/route.ts b/src/app/api/send/route.ts index 21e8790e..8d841270 100644 --- a/src/app/api/send/route.ts +++ b/src/app/api/send/route.ts @@ -88,11 +88,11 @@ export async function POST(request: Request) { const sessionId = uuid(websiteId, hostname, ip, userAgent); // Find session - if (!cache?.sessionId) { + if (!clickhouse.enabled && !cache?.sessionId) { const session = await fetchSession(websiteId, sessionId); // Create a session if not found - if (!session && !clickhouse.enabled) { + if (!session) { try { await createSession({ id: sessionId, @@ -133,7 +133,7 @@ export async function POST(request: Request) { let urlPath = currentUrl.pathname; const urlQuery = currentUrl.search.substring(1); - const urlDomain = currentUrl.hostname; + const urlDomain = currentUrl.hostname.replace(/^www./, ''); if (process.env.REMOVE_TRAILING_SLASH) { urlPath = urlPath.replace(/(.+)\/$/, '$1'); @@ -150,7 +150,7 @@ export async function POST(request: Request) { referrerQuery = referrerUrl.search.substring(1); if (referrerUrl.hostname !== 'localhost') { - referrerDomain = referrerUrl.hostname; + referrerDomain = referrerUrl.hostname.replace(/^www\./, ''); } } 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; } From 4d6ec631f74bac45fbcee0bd8e4ae91c19fcd134 Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Mon, 10 Feb 2025 21:07:18 -0800 Subject: [PATCH 4/6] Updated redis logic. --- package.json | 2 +- src/app/api/auth/login/route.ts | 17 +++++++++-------- src/app/api/auth/logout/route.ts | 8 +++----- src/app/api/auth/sso/route.ts | 4 ++-- src/lib/auth.ts | 18 ++++++++---------- src/lib/load.ts | 14 +++++--------- src/lib/redis.ts | 17 +++++++++++++++++ src/queries/prisma/website.ts | 10 +++------- yarn.lock | 8 ++++---- 9 files changed, 52 insertions(+), 46 deletions(-) create mode 100644 src/lib/redis.ts 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..766c788c 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 = null; + + 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/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/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 2c6eb5f9..3396d55a 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" From 6466cef2696c485130c529aad9baa9aa5e78b358 Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Mon, 10 Feb 2025 21:14:58 -0800 Subject: [PATCH 5/6] Return server error. --- src/app/api/auth/login/route.ts | 2 +- src/app/api/send/route.ts | 286 ++++++++++++++++---------------- 2 files changed, 146 insertions(+), 142 deletions(-) diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 766c788c..7ae22e2b 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -31,7 +31,7 @@ export async function POST(request: Request) { const { id, role, createdAt } = user; - let token = null; + let token: string; if (redis.enabled) { token = await saveAuth({ userId: id, role }); diff --git a/src/app/api/send/route.ts b/src/app/api/send/route.ts index 8d841270..fce649da 100644 --- a/src/app/api/send/route.ts +++ b/src/app/api/send/route.ts @@ -29,170 +29,174 @@ const schema = z.object({ }); 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 { 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 (!clickhouse.enabled && !cache?.sessionId) { - const session = await fetchSession(websiteId, sessionId); + if (cacheHeader) { + const result = await parseToken(cacheHeader, secret()); - // 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); + 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 ? `http://${hostname}` : 'http://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 }); } From e2523d2604f2426cdb58fde0ab395c4fc68ed6cb Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Mon, 10 Feb 2025 21:52:30 -0800 Subject: [PATCH 6/6] Check for valid urls. --- src/app/api/send/route.ts | 9 +++++---- src/lib/schema.ts | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/app/api/send/route.ts b/src/app/api/send/route.ts index fce649da..b70f5d19 100644 --- a/src/app/api/send/route.ts +++ b/src/app/api/send/route.ts @@ -9,6 +9,7 @@ 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']), @@ -17,11 +18,11 @@ const schema = z.object({ data: z.object({}).passthrough().optional(), hostname: z.string().max(100).optional(), language: z.string().max(35).optional(), - referrer: z.string().optional(), + referrer: urlOrPathParam, screen: z.string().max(11).optional(), title: z.string().optional(), - url: z.string().optional(), - name: z.string().max(50).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(), @@ -129,7 +130,7 @@ export async function POST(request: Request) { } if (type === COLLECTION_TYPE.event) { - const base = hostname ? `http://${hostname}` : 'http://localhost'; + const base = hostname ? `https://${hostname}` : 'https://localhost'; const currentUrl = new URL(url, base); let urlPath = currentUrl.pathname; 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',