diff options
Diffstat (limited to 'src/lib')
36 files changed, 3814 insertions, 0 deletions
diff --git a/src/lib/__tests__/charts.test.ts b/src/lib/__tests__/charts.test.ts new file mode 100644 index 0000000..e81be16 --- /dev/null +++ b/src/lib/__tests__/charts.test.ts @@ -0,0 +1,39 @@ +import { renderNumberLabels } from '../charts'; + +// test for renderNumberLabels + +describe('renderNumberLabels', () => { + test.each([ + ['1000000', '1.0m'], + ['2500000', '2.5m'], + ])("formats numbers ≥ 1 million as 'Xm' (%s → %s)", (input, expected) => { + expect(renderNumberLabels(input)).toBe(expected); + }); + + test.each([['150000', '150k']])("formats numbers ≥ 100K as 'Xk' (%s → %s)", (input, expected) => { + expect(renderNumberLabels(input)).toBe(expected); + }); + + test.each([ + ['12500', '12.5k'], + ])("formats numbers ≥ 10K as 'X.Xk' (%s → %s)", (input, expected) => { + expect(renderNumberLabels(input)).toBe(expected); + }); + + test.each([['1500', '1.50k']])("formats numbers ≥ 1K as 'X.XXk' (%s → %s)", (input, expected) => { + expect(renderNumberLabels(input)).toBe(expected); + }); + + test.each([ + ['999', '999'], + ])('calls formatNumber for values < 1000 (%s → %s)', (input, expected) => { + expect(renderNumberLabels(input)).toBe(expected); + }); + + test.each([ + ['0', '0'], + ['-5000', '-5000'], + ])('handles edge cases correctly (%s → %s)', (input, expected) => { + expect(renderNumberLabels(input)).toBe(expected); + }); +}); diff --git a/src/lib/__tests__/detect.test.ts b/src/lib/__tests__/detect.test.ts new file mode 100644 index 0000000..0395aef --- /dev/null +++ b/src/lib/__tests__/detect.test.ts @@ -0,0 +1,22 @@ +import { getIpAddress } from '../ip'; + +const IP = '127.0.0.1'; +const BAD_IP = '127.127.127.127'; + +test('getIpAddress: Custom header', () => { + process.env.CLIENT_IP_HEADER = 'x-custom-ip-header'; + + expect(getIpAddress(new Headers({ 'x-custom-ip-header': IP }))).toEqual(IP); +}); + +test('getIpAddress: CloudFlare header', () => { + expect(getIpAddress(new Headers({ 'cf-connecting-ip': IP }))).toEqual(IP); +}); + +test('getIpAddress: Standard header', () => { + expect(getIpAddress(new Headers({ 'x-forwarded-for': IP }))).toEqual(IP); +}); + +test('getIpAddress: No header', () => { + expect(getIpAddress(new Headers())).toEqual(null); +}); diff --git a/src/lib/__tests__/format.test.ts b/src/lib/__tests__/format.test.ts new file mode 100644 index 0000000..6e1b319 --- /dev/null +++ b/src/lib/__tests__/format.test.ts @@ -0,0 +1,38 @@ +import * as format from '../format'; + +test('parseTime', () => { + expect(format.parseTime(86400 + 3600 + 60 + 1)).toEqual({ + days: 1, + hours: 1, + minutes: 1, + seconds: 1, + ms: 0, + }); +}); + +test('formatTime', () => { + expect(format.formatTime(3600 + 60 + 1)).toBe('1:01:01'); +}); + +test('formatShortTime', () => { + expect(format.formatShortTime(3600 + 60 + 1)).toBe('1m1s'); + + expect(format.formatShortTime(3600 + 60 + 1, ['h', 'm', 's'])).toBe('1h1m1s'); +}); + +test('formatNumber', () => { + expect(format.formatNumber('10.2')).toBe('10'); + expect(format.formatNumber('10.5')).toBe('11'); +}); + +test('formatLongNumber', () => { + expect(format.formatLongNumber(1200000)).toBe('1.2m'); + expect(format.formatLongNumber(575000)).toBe('575k'); + expect(format.formatLongNumber(10500)).toBe('10.5k'); + expect(format.formatLongNumber(1200)).toBe('1.20k'); +}); + +test('stringToColor', () => { + expect(format.stringToColor('hello')).toBe('#d218e9'); + expect(format.stringToColor('goodbye')).toBe('#11e956'); +}); diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..ba6d8b0 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,80 @@ +import debug from 'debug'; +import { ROLE_PERMISSIONS, ROLES, SHARE_TOKEN_HEADER } from '@/lib/constants'; +import { secret } from '@/lib/crypto'; +import { getRandomChars } from '@/lib/generate'; +import { createSecureToken, parseSecureToken, parseToken } from '@/lib/jwt'; +import redis from '@/lib/redis'; +import { ensureArray } from '@/lib/utils'; +import { getUser } from '@/queries/prisma/user'; + +const log = debug('umami:auth'); + +export function getBearerToken(request: Request) { + const auth = request.headers.get('authorization'); + + return auth?.split(' ')[1]; +} + +export async function checkAuth(request: Request) { + const token = getBearerToken(request); + const payload = parseSecureToken(token, secret()); + const shareToken = await parseShareToken(request); + + let user = null; + const { userId, authKey } = payload || {}; + + if (userId) { + user = await getUser(userId); + } else if (redis.enabled && authKey) { + const key = await redis.client.get(authKey); + + if (key?.userId) { + user = await getUser(key.userId); + } + } + + log({ token, payload, authKey, shareToken, user }); + + if (!user?.id && !shareToken) { + log('User not authorized'); + return null; + } + + if (user) { + user.isAdmin = user.role === ROLES.admin; + } + + return { + token, + authKey, + shareToken, + user, + }; +} + +export async function saveAuth(data: any, expire = 0) { + const authKey = `auth:${getRandomChars(32)}`; + + if (redis.enabled) { + await redis.client.set(authKey, data); + + if (expire) { + await redis.client.expire(authKey, expire); + } + } + + return createSecureToken({ authKey }, secret()); +} + +export async function hasPermission(role: string, permission: string | string[]) { + return ensureArray(permission).some(e => ROLE_PERMISSIONS[role]?.includes(e)); +} + +export function parseShareToken(request: Request) { + try { + return parseToken(request.headers.get(SHARE_TOKEN_HEADER), secret()); + } catch (e) { + log(e); + return null; + } +} diff --git a/src/lib/charts.ts b/src/lib/charts.ts new file mode 100644 index 0000000..7d4208e --- /dev/null +++ b/src/lib/charts.ts @@ -0,0 +1,27 @@ +import { formatDate } from '@/lib/date'; +import { formatLongNumber } from '@/lib/format'; + +export function renderNumberLabels(label: string) { + return +label > 1000 ? formatLongNumber(+label) : label; +} + +export function renderDateLabels(unit: string, locale: string) { + return (label: string, index: number, values: any[]) => { + const d = new Date(values[index].value); + + switch (unit) { + case 'minute': + return formatDate(d, 'h:mm', locale); + case 'hour': + return formatDate(d, 'p', locale); + case 'day': + return formatDate(d, 'PP', locale).replace(/\W*20\d{2}\W*/, ''); // Remove year + case 'month': + return formatDate(d, 'MMM', locale); + case 'year': + return formatDate(d, 'yyyy', locale); + default: + return label; + } + }; +} diff --git a/src/lib/clickhouse.ts b/src/lib/clickhouse.ts new file mode 100644 index 0000000..f2ebbb7 --- /dev/null +++ b/src/lib/clickhouse.ts @@ -0,0 +1,273 @@ +import { type ClickHouseClient, createClient } from '@clickhouse/client'; +import { formatInTimeZone } from 'date-fns-tz'; +import debug from 'debug'; +import { CLICKHOUSE } from '@/lib/db'; +import { DEFAULT_PAGE_SIZE, FILTER_COLUMNS, OPERATORS } from './constants'; +import { filtersObjectToArray } from './params'; +import type { QueryFilters, QueryOptions } from './types'; + +export const CLICKHOUSE_DATE_FORMATS = { + utc: '%Y-%m-%dT%H:%i:%SZ', + second: '%Y-%m-%d %H:%i:%S', + minute: '%Y-%m-%d %H:%i:00', + hour: '%Y-%m-%d %H:00:00', + day: '%Y-%m-%d', + month: '%Y-%m-01', + year: '%Y-01-01', +}; + +const log = debug('umami:clickhouse'); + +let clickhouse: ClickHouseClient; +const enabled = Boolean(process.env.CLICKHOUSE_URL); + +function getClient() { + const { + hostname, + port, + pathname, + protocol, + username = 'default', + password, + } = new URL(process.env.CLICKHOUSE_URL); + + const client = createClient({ + url: `${protocol}//${hostname}:${port}`, + database: pathname.replace('/', ''), + username: username, + password, + }); + + if (process.env.NODE_ENV !== 'production') { + globalThis[CLICKHOUSE] = client; + } + + log('Clickhouse initialized'); + + return client; +} + +function getUTCString(date?: Date | string | number) { + return formatInTimeZone(date || new Date(), 'UTC', 'yyyy-MM-dd HH:mm:ss'); +} + +function getDateStringSQL(data: any, unit: string = 'utc', timezone?: string) { + if (timezone) { + return `formatDateTime(${data}, '${CLICKHOUSE_DATE_FORMATS[unit]}', '${timezone}')`; + } + + return `formatDateTime(${data}, '${CLICKHOUSE_DATE_FORMATS[unit]}')`; +} + +function getDateSQL(field: string, unit: string, timezone?: string) { + if (timezone) { + return `toDateTime(date_trunc('${unit}', ${field}, '${timezone}'))`; + } + return `toDateTime(date_trunc('${unit}', ${field}))`; +} + +function getSearchSQL(column: string, param: string = 'search'): string { + return `and positionCaseInsensitive(${column}, {${param}:String}) > 0`; +} + +function mapFilter(column: string, operator: string, name: string, type: string = 'String') { + const value = `{${name}:${type}}`; + + switch (operator) { + case OPERATORS.equals: + return `${column} = ${value}`; + case OPERATORS.notEquals: + return `${column} != ${value}`; + case OPERATORS.contains: + return `positionCaseInsensitive(${column}, ${value}) > 0`; + case OPERATORS.doesNotContain: + return `positionCaseInsensitive(${column}, ${value}) = 0`; + default: + return ''; + } +} + +function getFilterQuery(filters: Record<string, any>, options: QueryOptions = {}) { + const query = filtersObjectToArray(filters, options).reduce((arr, { name, column, operator }) => { + const isCohort = options?.isCohort; + + if (isCohort) { + column = FILTER_COLUMNS[name.slice('cohort_'.length)]; + } + + if (column) { + if (name === 'eventType') { + arr.push(`and ${mapFilter(column, operator, name, 'UInt32')}`); + } else { + arr.push(`and ${mapFilter(column, operator, name)}`); + } + + if (name === 'referrer') { + arr.push(`and referrer_domain != hostname`); + } + } + + return arr; + }, []); + + return query.join('\n'); +} + +function getCohortQuery(filters: Record<string, any>) { + if (!filters || Object.keys(filters).length === 0) { + return ''; + } + + const filterQuery = getFilterQuery(filters, { isCohort: true }); + + return `join ( + select distinct session_id + from website_event + where website_id = {websiteId:UUID} + and created_at between {cohort_startDate:DateTime64} and {cohort_endDate:DateTime64} + ${filterQuery} + ) as cohort + on cohort.session_id = website_event.session_id + `; +} + +function getDateQuery(filters: Record<string, any>) { + const { startDate, endDate, timezone } = filters; + + if (startDate) { + if (endDate) { + if (timezone) { + return `and created_at between toTimezone({startDate:DateTime64},{timezone:String}) and toTimezone({endDate:DateTime64},{timezone:String})`; + } + return `and created_at between {startDate:DateTime64} and {endDate:DateTime64}`; + } else { + if (timezone) { + return `and created_at >= toTimezone({startDate:DateTime64},{timezone:String})`; + } + return `and created_at >= {startDate:DateTime64}`; + } + } + + return ''; +} + +function getQueryParams(filters: Record<string, any>) { + return { + ...filters, + ...filtersObjectToArray(filters).reduce((obj, { name, value }) => { + if (name && value !== undefined) { + obj[name] = value; + } + + return obj; + }, {}), + }; +} + +function parseFilters(filters: Record<string, any>, options?: QueryOptions) { + const cohortFilters = Object.fromEntries( + Object.entries(filters).filter(([key]) => key.startsWith('cohort_')), + ); + + return { + filterQuery: getFilterQuery(filters, options), + dateQuery: getDateQuery(filters), + queryParams: getQueryParams(filters), + cohortQuery: getCohortQuery(cohortFilters), + }; +} + +async function pagedRawQuery( + query: string, + queryParams: Record<string, any>, + filters: QueryFilters, + name?: string, +) { + const { page = 1, pageSize, orderBy, sortDescending = false, search } = filters; + const size = +pageSize || DEFAULT_PAGE_SIZE; + const offset = +size * (+page - 1); + const direction = sortDescending ? 'desc' : 'asc'; + + const statements = [ + orderBy && `order by ${orderBy} ${direction}`, + +size > 0 && `limit ${+size} offset ${+offset}`, + ] + .filter(n => n) + .join('\n'); + + const count = await rawQuery(`select count(*) as num from (${query}) t`, queryParams).then( + res => res[0].num, + ); + + const data = await rawQuery(`${query}${statements}`, queryParams, name); + + return { data, count, page: +page, pageSize: size, orderBy, search }; +} + +async function rawQuery<T = unknown>( + query: string, + params: Record<string, unknown> = {}, + name?: string, +): Promise<T> { + if (process.env.LOG_QUERY) { + log({ query, params, name }); + } + + await connect(); + + const resultSet = await clickhouse.query({ + query: query, + query_params: params, + format: 'JSONEachRow', + clickhouse_settings: { + date_time_output_format: 'iso', + output_format_json_quote_64bit_integers: 0, + }, + }); + + return (await resultSet.json()) as T; +} + +async function insert(table: string, values: any[]) { + await connect(); + + return clickhouse.insert({ table, values, format: 'JSONEachRow' }); +} + +async function findUnique(data: any[]) { + if (data.length > 1) { + throw `${data.length} records found when expecting 1.`; + } + + return findFirst(data); +} + +async function findFirst(data: any[]) { + return data[0] ?? null; +} + +async function connect() { + if (enabled && !clickhouse) { + clickhouse = process.env.CLICKHOUSE_URL && (globalThis[CLICKHOUSE] || getClient()); + } + + return clickhouse; +} + +export default { + enabled, + client: clickhouse, + log, + connect, + getDateStringSQL, + getDateSQL, + getSearchSQL, + getFilterQuery, + getUTCString, + parseFilters, + pagedRawQuery, + findUnique, + findFirst, + rawQuery, + insert, +}; diff --git a/src/lib/client.ts b/src/lib/client.ts new file mode 100644 index 0000000..e176215 --- /dev/null +++ b/src/lib/client.ts @@ -0,0 +1,14 @@ +import { getItem, removeItem, setItem } from '@/lib/storage'; +import { AUTH_TOKEN } from './constants'; + +export function getClientAuthToken() { + return getItem(AUTH_TOKEN); +} + +export function setClientAuthToken(token: string) { + setItem(AUTH_TOKEN, token); +} + +export function removeClientAuthToken() { + removeItem(AUTH_TOKEN); +} diff --git a/src/lib/colors.ts b/src/lib/colors.ts new file mode 100644 index 0000000..2ae9bda --- /dev/null +++ b/src/lib/colors.ts @@ -0,0 +1,91 @@ +import { colord } from 'colord'; +import { THEME_COLORS } from '@/lib/constants'; + +export function hex6(str: string) { + let h = 0x811c9dc5; // FNV-1a 32-bit offset + for (let i = 0; i < str.length; i++) { + h ^= str.charCodeAt(i); + h = (h >>> 0) * 0x01000193; // FNV prime + } + // use lower 24 bits; pad to 6 hex chars + return ((h >>> 0) & 0xffffff).toString(16).padStart(6, '0'); +} + +export const pick = (num: number, arr: any[]) => { + return arr[num % arr.length]; +}; + +export function clamp(num: number, min: number, max: number) { + return num < min ? min : num > max ? max : num; +} + +export function hex2RGB(color: string, min: number = 0, max: number = 255) { + const c = color.replace(/^#/, ''); + const diff = max - min; + + const normalize = (num: number) => { + return Math.floor((num / 255) * diff + min); + }; + + const r = normalize(parseInt(c.substring(0, 2), 16)); + const g = normalize(parseInt(c.substring(2, 4), 16)); + const b = normalize(parseInt(c.substring(4, 6), 16)); + + return { r, g, b }; +} + +export function rgb2Hex(r: number, g: number, b: number, prefix = '') { + return `${prefix}${r.toString(16)}${g.toString(16)}${b.toString(16)}`; +} + +export function getPastel(color: string, factor: number = 0.5, prefix = '') { + let { r, g, b } = hex2RGB(color); + + r = Math.floor((r + 255 * factor) / (1 + factor)); + g = Math.floor((g + 255 * factor) / (1 + factor)); + b = Math.floor((b + 255 * factor) / (1 + factor)); + + return rgb2Hex(r, g, b, prefix); +} + +export function getColor(seed: string, min: number = 0, max: number = 255) { + const color = hex6(seed); + const { r, g, b } = hex2RGB(color, min, max); + + return rgb2Hex(r, g, b); +} + +export function getThemeColors(theme: string) { + const { primary, text, line, fill } = THEME_COLORS[theme]; + const primaryColor = colord(THEME_COLORS[theme].primary); + + return { + colors: { + theme: { + ...THEME_COLORS[theme], + }, + chart: { + text, + line, + views: { + hoverBackgroundColor: primaryColor.alpha(0.7).toRgbString(), + backgroundColor: primaryColor.alpha(0.4).toRgbString(), + borderColor: primaryColor.alpha(0.7).toRgbString(), + hoverBorderColor: primaryColor.toRgbString(), + }, + visitors: { + hoverBackgroundColor: primaryColor.alpha(0.9).toRgbString(), + backgroundColor: primaryColor.alpha(0.6).toRgbString(), + borderColor: primaryColor.alpha(0.9).toRgbString(), + hoverBorderColor: primaryColor.toRgbString(), + }, + }, + map: { + baseColor: primary, + fillColor: fill, + strokeColor: primary, + hoverColor: primary, + }, + }, + }; +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts new file mode 100644 index 0000000..e5090c3 --- /dev/null +++ b/src/lib/constants.ts @@ -0,0 +1,682 @@ +export const CURRENT_VERSION = process.env.currentVersion; +export const AUTH_TOKEN = 'umami.auth'; +export const LOCALE_CONFIG = 'umami.locale'; +export const TIMEZONE_CONFIG = 'umami.timezone'; +export const DATE_RANGE_CONFIG = 'umami.date-range'; +export const THEME_CONFIG = 'umami.theme'; +export const DASHBOARD_CONFIG = 'umami.dashboard'; +export const LAST_TEAM_CONFIG = 'umami.last-team'; +export const VERSION_CHECK = 'umami.version-check'; +export const SHARE_TOKEN_HEADER = 'x-umami-share-token'; +export const HOMEPAGE_URL = 'https://umami.is'; +export const DOCS_URL = 'https://umami.is/docs'; +export const REPO_URL = 'https://github.com/umami-software/umami'; +export const UPDATES_URL = 'https://api.umami.is/v1/updates'; +export const TELEMETRY_PIXEL = 'https://i.umami.is/a.png'; +export const FAVICON_URL = 'https://icons.duckduckgo.com/ip3/{{domain}}.ico'; +export const LINKS_URL = `${globalThis?.location?.origin}/q`; +export const PIXELS_URL = `${globalThis?.location?.origin}/p`; + +export const DEFAULT_LOCALE = 'en-US'; +export const DEFAULT_THEME = 'light'; +export const DEFAULT_ANIMATION_DURATION = 300; +export const DEFAULT_DATE_RANGE_VALUE = '24hour'; +export const DEFAULT_WEBSITE_LIMIT = 10; +export const DEFAULT_RESET_DATE = '2000-01-01'; +export const DEFAULT_PAGE_SIZE = 20; +export const DEFAULT_DATE_COMPARE = 'prev'; + +export const REALTIME_RANGE = 30; +export const REALTIME_INTERVAL = 10000; + +export const UNIT_TYPES = ['year', 'month', 'hour', 'day', 'minute']; + +export const EVENT_COLUMNS = [ + 'path', + 'entry', + 'exit', + 'referrer', + 'domain', + 'title', + 'query', + 'event', + 'tag', + 'hostname', +]; + +export const SESSION_COLUMNS = [ + 'browser', + 'os', + 'device', + 'screen', + 'language', + 'country', + 'city', + 'region', +]; + +export const SEGMENT_TYPES = { + segment: 'segment', + cohort: 'cohort', +}; + +export const FILTER_COLUMNS = { + path: 'url_path', + entry: 'url_path', + exit: 'url_path', + referrer: 'referrer_domain', + domain: 'referrer_domain', + hostname: 'hostname', + title: 'page_title', + query: 'url_query', + os: 'os', + browser: 'browser', + device: 'device', + country: 'country', + region: 'region', + city: 'city', + language: 'language', + event: 'event_name', + tag: 'tag', + eventType: 'event_type', +}; + +export const COLLECTION_TYPE = { + event: 'event', + identify: 'identify', +} as const; + +export const EVENT_TYPE = { + pageView: 1, + customEvent: 2, + linkEvent: 3, + pixelEvent: 4, +} as const; + +export const DATA_TYPE = { + string: 1, + number: 2, + boolean: 3, + date: 4, + array: 5, +} as const; + +export const OPERATORS = { + equals: 'eq', + notEquals: 'neq', + set: 's', + notSet: 'ns', + contains: 'c', + doesNotContain: 'dnc', + true: 't', + false: 'f', + greaterThan: 'gt', + lessThan: 'lt', + greaterThanEquals: 'gte', + lessThanEquals: 'lte', + before: 'bf', + after: 'af', +} as const; + +export const DATA_TYPES = { + [DATA_TYPE.string]: 'string', + [DATA_TYPE.number]: 'number', + [DATA_TYPE.boolean]: 'boolean', + [DATA_TYPE.date]: 'date', + [DATA_TYPE.array]: 'array', +} as const; + +export const ROLES = { + admin: 'admin', + user: 'user', + viewOnly: 'view-only', + teamOwner: 'team-owner', + teamManager: 'team-manager', + teamMember: 'team-member', + teamViewOnly: 'team-view-only', +} as const; + +export const PERMISSIONS = { + all: 'all', + websiteCreate: 'website:create', + websiteUpdate: 'website:update', + websiteDelete: 'website:delete', + websiteTransferToTeam: 'website:transfer-to-team', + websiteTransferToUser: 'website:transfer-to-user', + teamCreate: 'team:create', + teamUpdate: 'team:update', + teamDelete: 'team:delete', +} as const; + +export const ROLE_PERMISSIONS = { + [ROLES.admin]: [PERMISSIONS.all], + [ROLES.user]: [ + PERMISSIONS.websiteCreate, + PERMISSIONS.websiteUpdate, + PERMISSIONS.websiteDelete, + PERMISSIONS.teamCreate, + ], + [ROLES.viewOnly]: [], + [ROLES.teamOwner]: [ + PERMISSIONS.teamUpdate, + PERMISSIONS.teamDelete, + PERMISSIONS.websiteCreate, + PERMISSIONS.websiteUpdate, + PERMISSIONS.websiteDelete, + PERMISSIONS.websiteTransferToTeam, + PERMISSIONS.websiteTransferToUser, + ], + [ROLES.teamManager]: [ + PERMISSIONS.teamUpdate, + PERMISSIONS.websiteCreate, + PERMISSIONS.websiteUpdate, + PERMISSIONS.websiteDelete, + PERMISSIONS.websiteTransferToTeam, + ], + [ROLES.teamMember]: [ + PERMISSIONS.websiteCreate, + PERMISSIONS.websiteUpdate, + PERMISSIONS.websiteDelete, + ], + [ROLES.teamViewOnly]: [], +} as const; + +export const THEME_COLORS = { + light: { + primary: '#2680eb', + text: '#838383', + line: '#d9d9d9', + fill: '#f9f9f9', + }, + dark: { + primary: '#2680eb', + text: '#7b7b7b', + line: '#3a3a3a', + fill: '#191919', + }, +} as const; + +export const CHART_COLORS = [ + '#2680eb', + '#9256d9', + '#44b556', + '#e68619', + '#e34850', + '#f7bd12', + '#01bad7', + '#6734bc', + '#89c541', + '#ffc301', + '#ec1562', + '#ffec16', +]; + +export const DOMAIN_REGEX = + /^(localhost(:[1-9]\d{0,4})?|((?=[a-z0-9-_]{1,63}\.)(xn--)?[a-z0-9-_]+(-[a-z0-9-_]+)*\.)+(xn--)?[a-z0-9-_]{2,63})$/; +export const SHARE_ID_REGEX = /^[a-zA-Z0-9]{8,50}$/; +export const DATETIME_REGEX = + /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{3}(Z|\+[0-9]{2}:[0-9]{2})?)?$/; + +export const URL_LENGTH = 500; +export const PAGE_TITLE_LENGTH = 500; +export const EVENT_NAME_LENGTH = 50; + +export const UTM_PARAMS = ['utm_campaign', 'utm_content', 'utm_medium', 'utm_source', 'utm_term']; + +export const OS_NAMES = { + 'Android OS': 'Android', + 'Chrome OS': 'ChromeOS', + 'Mac OS': 'macOS', + 'Sun OS': 'SunOS', + 'Windows 10': 'Windows 10/11', +} as const; + +export const BROWSERS = { + android: 'Android', + aol: 'AOL', + bb10: 'BlackBerry 10', + beaker: 'Beaker', + chrome: 'Chrome', + 'chromium-webview': 'Chrome (webview)', + crios: 'Chrome (iOS)', + curl: 'Curl', + edge: 'Edge', + 'edge-chromium': 'Edge (Chromium)', + 'edge-ios': 'Edge (iOS)', + facebook: 'Facebook', + firefox: 'Firefox', + fxios: 'Firefox (iOS)', + ie: 'IE', + instagram: 'Instagram', + ios: 'iOS', + 'ios-webview': 'iOS (webview)', + kakaotalk: 'KakaoTalk', + miui: 'MIUI', + opera: 'Opera', + 'opera-mini': 'Opera Mini', + phantomjs: 'PhantomJS', + safari: 'Safari', + samsung: 'Samsung', + searchbot: 'Searchbot', + silk: 'Silk', + yandexbrowser: 'Yandex', +} as const; + +export const SOCIAL_DOMAINS = [ + 'bsky.app', + 'facebook.com', + 'fb.com', + 'ig.com', + 'instagram.com', + 'linkedin.', + 'news.ycombinator.com', + 'pinterest.', + 'reddit.', + 'snapchat.', + 't.co', + 'threads.net', + 'tiktok.', + 'twitter.com', + 'x.com', +]; + +export const SEARCH_DOMAINS = [ + 'baidu.com', + 'bing.com', + 'chatgpt.com', + 'duckduckgo.com', + 'ecosia.org', + 'google.', + 'msn.com', + 'perplexity.ai', + 'search.brave.com', + 'yandex.', +]; + +export const SHOPPING_DOMAINS = [ + 'alibaba.com', + 'aliexpress.com', + 'amazon.', + 'bestbuy.com', + 'ebay.com', + 'etsy.com', + 'newegg.com', + 'target.com', + 'walmart.com', +]; + +export const EMAIL_DOMAINS = [ + 'gmail.', + 'hotmail.', + 'mail.yahoo.', + 'outlook.', + 'proton.me', + 'protonmail.', +]; + +export const VIDEO_DOMAINS = ['twitch.', 'youtube.']; + +export const PAID_AD_PARAMS = [ + 'ad_id=', + 'aid=', + 'dclid=', + 'epik=', + 'fbclid=', + 'gclid=', + 'li_fat_id=', + 'msclkid=', + 'ob_click_id=', + 'pc_id=', + 'rdt_cid=', + 'scid=', + 'ttclid=', + 'twclid=', + 'utm_medium=cpc', + 'utm_medium=paid', + 'utm_medium=paid_social', + 'utm_source=google', +]; + +export const GROUPED_DOMAINS = [ + { name: 'Baidu', domain: 'baidu.com', match: 'baidu.' }, + { name: 'Bing', domain: 'bing.com', match: 'bing.' }, + { name: 'Brave', domain: 'brave.com', match: 'brave.' }, + { name: 'ChatGPT', domain: 'chatgpt.com', match: 'chatgpt.' }, + { name: 'DuckDuckGo', domain: 'duckduckgo.com', match: 'duckduckgo.' }, + { name: 'Facebook', domain: 'facebook.com', match: 'facebook.' }, + { name: 'GitHub', domain: 'github.com', match: 'github.' }, + { name: 'Google', domain: 'google.com', match: 'google.' }, + { name: 'Hacker News', domain: 'news.ycombinator.com', match: 'news.ycombinator.com' }, + { name: 'Instagram', domain: 'instagram.com', match: ['instagram.', 'ig.com'] }, + { name: 'LinkedIn', domain: 'linkedin.com', match: 'linkedin.' }, + { name: 'Pinterest', domain: 'pinterest.com', match: 'pinterest.' }, + { name: 'Reddit', domain: 'reddit.com', match: 'reddit.' }, + { name: 'Snapchat', domain: 'snapchat.com', match: 'snapchat.' }, + { name: 'Twitter', domain: 'twitter.com', match: ['twitter.', 't.co', 'x.com'] }, + { name: 'Yahoo', domain: 'yahoo.com', match: 'yahoo.' }, + { name: 'Yandex', domain: 'yandex.ru', match: 'yandex.' }, +]; + +export const MAP_FILE = '/datamaps.world.json'; + +export const ISO_COUNTRIES = { + ABW: 'AW', + AFG: 'AF', + AGO: 'AO', + AIA: 'AI', + ALA: 'AX', + ALB: 'AL', + AND: 'AD', + ANT: 'AN', + ARE: 'AE', + ARG: 'AR', + ARM: 'AM', + ASM: 'AS', + ATF: 'TF', + ATG: 'AG', + AUS: 'AU', + AUT: 'AT', + AZE: 'AZ', + BDI: 'BI', + BEL: 'BE', + BEN: 'BJ', + BFA: 'BF', + BGD: 'BD', + BGR: 'BG', + BHR: 'BH', + BHS: 'BS', + BIH: 'BA', + BLR: 'BY', + BLZ: 'BZ', + BLM: 'BL', + BMU: 'BM', + BOL: 'BO', + BRA: 'BR', + BRB: 'BB', + BRN: 'BN', + BTN: 'BT', + BVT: 'BV', + BWA: 'BW', + CAF: 'CF', + CAN: 'CA', + CCK: 'CC', + CHE: 'CH', + CHL: 'CL', + CHN: 'CN', + CIV: 'CI', + CMR: 'CM', + COD: 'CD', + COG: 'CG', + COK: 'CK', + COL: 'CO', + COM: 'KM', + CPV: 'CV', + CRI: 'CR', + CUB: 'CU', + CXR: 'CX', + CYM: 'KY', + CYP: 'CY', + CZE: 'CZ', + DEU: 'DE', + DJI: 'DJ', + DMA: 'DM', + DNK: 'DK', + DOM: 'DO', + DZA: 'DZ', + ECU: 'EC', + EGY: 'EG', + ERI: 'ER', + ESH: 'EH', + ESP: 'ES', + EST: 'EE', + ETH: 'ET', + FIN: 'FI', + FJI: 'FJ', + FLK: 'FK', + FRA: 'FR', + FRO: 'FO', + FSM: 'FM', + GAB: 'GA', + GBR: 'GB', + GEO: 'GE', + GGY: 'GG', + GHA: 'GH', + GIB: 'GI', + GIN: 'GN', + GLP: 'GP', + GMB: 'GM', + GNB: 'GW', + GNQ: 'GQ', + GRC: 'GR', + GRD: 'GD', + GRL: 'GL', + GTM: 'GT', + GUF: 'GF', + GUM: 'GU', + GUY: 'GY', + HKG: 'HK', + HMD: 'HM', + HND: 'HN', + HRV: 'HR', + HTI: 'HT', + HUN: 'HU', + IDN: 'ID', + IMN: 'IM', + IND: 'IN', + IOT: 'IO', + IRL: 'IE', + IRN: 'IR', + IRQ: 'IQ', + ISL: 'IS', + ISR: 'IL', + ITA: 'IT', + JAM: 'JM', + JEY: 'JE', + JOR: 'JO', + JPN: 'JP', + KAZ: 'KZ', + KEN: 'KE', + KGZ: 'KG', + KHM: 'KH', + KIR: 'KI', + KNA: 'KN', + KOR: 'KR', + KWT: 'KW', + LAO: 'LA', + LBN: 'LB', + LBR: 'LR', + LBY: 'LY', + LCA: 'LC', + LIE: 'LI', + LKA: 'LK', + LSO: 'LS', + LTU: 'LT', + LUX: 'LU', + LVA: 'LV', + MAF: 'MF', + MAR: 'MA', + MCO: 'MC', + MDA: 'MD', + MDG: 'MG', + MDV: 'MV', + MEX: 'MX', + MHL: 'MH', + MKD: 'MK', + MLI: 'ML', + MLT: 'MT', + MMR: 'MM', + MNE: 'ME', + MNG: 'MN', + MNP: 'MP', + MOZ: 'MZ', + MRT: 'MR', + MSR: 'MS', + MTQ: 'MQ', + MUS: 'MU', + MWI: 'MW', + MYS: 'MY', + MYT: 'YT', + NAM: 'NA', + NCL: 'NC', + NER: 'NE', + NFK: 'NF', + NGA: 'NG', + NIC: 'NI', + NIU: 'NU', + NLD: 'NL', + NOR: 'NO', + NPL: 'NP', + NRU: 'NR', + NZL: 'NZ', + OMN: 'OM', + PAK: 'PK', + PAN: 'PA', + PCN: 'PN', + PER: 'PE', + PHL: 'PH', + PLW: 'PW', + PNG: 'PG', + POL: 'PL', + PRI: 'PR', + PRK: 'KP', + PRT: 'PT', + PRY: 'PY', + PSE: 'PS', + PYF: 'PF', + QAT: 'QA', + REU: 'RE', + ROU: 'RO', + RUS: 'RU', + RWA: 'RW', + SAU: 'SA', + SDN: 'SD', + SEN: 'SN', + SGP: 'SG', + SGS: 'GS', + SHN: 'SH', + SJM: 'SJ', + SLB: 'SB', + SLE: 'SL', + SLV: 'SV', + SMR: 'SM', + SOM: 'SO', + SPM: 'PM', + SRB: 'RS', + SUR: 'SR', + STP: 'ST', + SVK: 'SK', + SVN: 'SI', + SWE: 'SE', + SWZ: 'SZ', + SYC: 'SC', + SYR: 'SY', + TCA: 'TC', + TCD: 'TD', + TGO: 'TG', + THA: 'TH', + TJK: 'TJ', + TKL: 'TK', + TKM: 'TM', + TLS: 'TL', + TON: 'TO', + TTO: 'TT', + TUN: 'TN', + TUR: 'TR', + TUV: 'TV', + TWN: 'TW', + TZA: 'TZ', + UGA: 'UG', + UKR: 'UA', + UMI: 'UM', + URY: 'UY', + USA: 'US', + UZB: 'UZ', + VAT: 'VA', + VCT: 'VC', + VEN: 'VE', + VGB: 'VG', + VIR: 'VI', + VNM: 'VN', + VUT: 'VU', + WLF: 'WF', + WSM: 'WS', + XKX: 'XK', + YEM: 'YE', + ZAF: 'ZA', + ZMB: 'ZM', + ZWE: 'ZW', +}; + +export const CURRENCIES = [ + { id: 'USD', name: 'US Dollar' }, + { id: 'EUR', name: 'Euro' }, + { id: 'GBP', name: 'British Pound' }, + { id: 'JPY', name: 'Japanese Yen' }, + { id: 'CNY', name: 'Chinese Renminbi (Yuan)' }, + { id: 'CAD', name: 'Canadian Dollar' }, + { id: 'HKD', name: 'Hong Kong Dollar' }, + { id: 'AUD', name: 'Australian Dollar' }, + { id: 'SGD', name: 'Singapore Dollar' }, + { id: 'CHF', name: 'Swiss Franc' }, + { id: 'SEK', name: 'Swedish Krona' }, + { id: 'PLN', name: 'Polish Złoty' }, + { id: 'NOK', name: 'Norwegian Krone' }, + { id: 'DKK', name: 'Danish Krone' }, + { id: 'NZD', name: 'New Zealand Dollar' }, + { id: 'ZAR', name: 'South African Rand' }, + { id: 'MXN', name: 'Mexican Peso' }, + { id: 'THB', name: 'Thai Baht' }, + { id: 'HUF', name: 'Hungarian Forint' }, + { id: 'MYR', name: 'Malaysian Ringgit' }, + { id: 'INR', name: 'Indian Rupee' }, + { id: 'KRW', name: 'South Korean Won' }, + { id: 'BRL', name: 'Brazilian Real' }, + { id: 'TRY', name: 'Turkish Lira' }, + { id: 'CZK', name: 'Czech Koruna' }, + { id: 'ILS', name: 'Israeli New Shekel' }, + { id: 'RUB', name: 'Russian Ruble' }, + { id: 'AED', name: 'United Arab Emirates Dirham' }, + { id: 'IDR', name: 'Indonesian Rupiah' }, + { id: 'PHP', name: 'Philippine Peso' }, + { id: 'RON', name: 'Romanian Leu' }, + { id: 'COP', name: 'Colombian Peso' }, + { id: 'SAR', name: 'Saudi Riyal' }, + { id: 'ARS', name: 'Argentine Peso' }, + { id: 'VND', name: 'Vietnamese Dong' }, + { id: 'CLP', name: 'Chilean Peso' }, + { id: 'EGP', name: 'Egyptian Pound' }, + { id: 'KWD', name: 'Kuwaiti Dinar' }, + { id: 'PKR', name: 'Pakistani Rupee' }, + { id: 'QAR', name: 'Qatari Riyal' }, + { id: 'BHD', name: 'Bahraini Dinar' }, + { id: 'UAH', name: 'Ukrainian Hryvnia' }, + { id: 'PEN', name: 'Peruvian Sol' }, + { id: 'BDT', name: 'Bangladeshi Taka' }, + { id: 'MAD', name: 'Moroccan Dirham' }, + { id: 'KES', name: 'Kenyan Shilling' }, + { id: 'NGN', name: 'Nigerian Naira' }, + { id: 'TND', name: 'Tunisian Dinar' }, + { id: 'OMR', name: 'Omani Rial' }, + { id: 'GHS', name: 'Ghanaian Cedi' }, +]; + +export const TIMEZONE_LEGACY: Record<string, string> = { + 'Asia/Batavia': 'Asia/Jakarta', + 'Asia/Calcutta': 'Asia/Kolkata', + 'Asia/Chongqing': 'Asia/Shanghai', + 'Asia/Harbin': 'Asia/Shanghai', + 'Asia/Jayapura': 'Asia/Pontianak', + 'Asia/Katmandu': 'Asia/Kathmandu', + 'Asia/Macao': 'Asia/Macau', + 'Asia/Rangoon': 'Asia/Yangon', + 'Asia/Saigon': 'Asia/Ho_Chi_Minh', + 'Europe/Kiev': 'Europe/Kyiv', + 'Europe/Zaporozhye': 'Europe/Kyiv', + 'Etc/UTC': 'UTC', + 'US/Arizona': 'America/Phoenix', + 'US/Central': 'America/Chicago', + 'US/Eastern': 'America/New_York', + 'US/Mountain': 'America/Denver', + 'US/Pacific': 'America/Los_Angeles', + 'US/Samoa': 'Pacific/Pago_Pago', +}; diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts new file mode 100644 index 0000000..a6d912b --- /dev/null +++ b/src/lib/crypto.ts @@ -0,0 +1,65 @@ +import crypto from 'node:crypto'; +import { v4, v5, v7 } from 'uuid'; + +const ALGORITHM = 'aes-256-gcm'; +const IV_LENGTH = 16; +const SALT_LENGTH = 64; +const TAG_LENGTH = 16; +const TAG_POSITION = SALT_LENGTH + IV_LENGTH; +const ENC_POSITION = TAG_POSITION + TAG_LENGTH; + +const HASH_ALGO = 'sha512'; +const HASH_ENCODING = 'hex'; + +const getKey = (password: string, salt: Buffer) => + crypto.pbkdf2Sync(password, salt, 10000, 32, 'sha512'); + +export function encrypt(value: any, secret: any) { + const iv = crypto.randomBytes(IV_LENGTH); + const salt = crypto.randomBytes(SALT_LENGTH); + const key = getKey(secret, salt); + + const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + + const encrypted = Buffer.concat([cipher.update(String(value), 'utf8'), cipher.final()]); + + const tag = cipher.getAuthTag(); + + return Buffer.concat([salt, iv, tag, encrypted]).toString('base64'); +} + +export function decrypt(value: any, secret: any) { + const str = Buffer.from(String(value), 'base64'); + const salt = str.subarray(0, SALT_LENGTH); + const iv = str.subarray(SALT_LENGTH, TAG_POSITION); + const tag = str.subarray(TAG_POSITION, ENC_POSITION); + const encrypted = str.subarray(ENC_POSITION); + + const key = getKey(secret, salt); + + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); + + decipher.setAuthTag(tag); + + return decipher.update(encrypted) + decipher.final('utf8'); +} + +export function hash(...args: string[]) { + return crypto.createHash(HASH_ALGO).update(args.join('')).digest(HASH_ENCODING); +} + +export function md5(...args: string[]) { + return crypto.createHash('md5').update(args.join('')).digest('hex'); +} + +export function secret() { + return hash(process.env.APP_SECRET || process.env.DATABASE_URL); +} + +export function uuid(...args: any) { + if (args.length) { + return v5(hash(...args, secret()), v5.DNS); + } + + return process.env.USE_UUIDV7 ? v7() : v4(); +} diff --git a/src/lib/data.ts b/src/lib/data.ts new file mode 100644 index 0000000..fe69edf --- /dev/null +++ b/src/lib/data.ts @@ -0,0 +1,94 @@ +import { DATA_TYPE, DATETIME_REGEX } from './constants'; +import type { DynamicDataType } from './types'; + +export function flattenJSON( + eventData: Record<string, any>, + keyValues: { key: string; value: any; dataType: DynamicDataType }[] = [], + parentKey = '', +): { key: string; value: any; dataType: DynamicDataType }[] { + return Object.keys(eventData).reduce( + (acc, key) => { + const value = eventData[key]; + const type = typeof eventData[key]; + + // nested object + if (value && type === 'object' && !Array.isArray(value) && !isValidDateValue(value)) { + flattenJSON(value, acc.keyValues, getKeyName(key, parentKey)); + } else { + createKey(getKeyName(key, parentKey), value, acc); + } + + return acc; + }, + { keyValues, parentKey }, + ).keyValues; +} + +export function isValidDateValue(value: string) { + return typeof value === 'string' && DATETIME_REGEX.test(value); +} + +export function getDataType(value: any): string { + let type: string = typeof value; + + if (isValidDateValue(value)) { + type = 'date'; + } + + return type; +} + +export function getStringValue(value: string, dataType: number) { + if (dataType === DATA_TYPE.number) { + return parseFloat(value).toFixed(4); + } + + if (dataType === DATA_TYPE.date) { + return new Date(value).toISOString(); + } + + return value; +} + +function createKey(key: string, value: string, acc: { keyValues: any[]; parentKey: string }) { + const type = getDataType(value); + + let dataType = null; + + switch (type) { + case 'number': + dataType = DATA_TYPE.number; + break; + case 'string': + dataType = DATA_TYPE.string; + break; + case 'boolean': + dataType = DATA_TYPE.boolean; + value = value ? 'true' : 'false'; + break; + case 'date': + dataType = DATA_TYPE.date; + break; + case 'object': + dataType = DATA_TYPE.array; + value = JSON.stringify(value); + break; + default: + dataType = DATA_TYPE.string; + break; + } + + acc.keyValues.push({ key, value, dataType }); +} + +function getKeyName(key: string, parentKey: string) { + if (!parentKey) { + return key; + } + + return `${parentKey}.${key}`; +} + +export function objectToArray(obj: object) { + return Object.keys(obj).map(key => obj[key]); +} diff --git a/src/lib/date.ts b/src/lib/date.ts new file mode 100644 index 0000000..3c1fd1b --- /dev/null +++ b/src/lib/date.ts @@ -0,0 +1,375 @@ +import { + addDays, + addHours, + addMinutes, + addMonths, + addWeeks, + addYears, + differenceInCalendarDays, + differenceInCalendarMonths, + differenceInCalendarWeeks, + differenceInCalendarYears, + differenceInHours, + differenceInMinutes, + endOfDay, + endOfHour, + endOfMinute, + endOfMonth, + endOfWeek, + endOfYear, + format, + isBefore, + isDate, + isEqual, + isSameDay, + max, + min, + startOfDay, + startOfHour, + startOfMinute, + startOfMonth, + startOfWeek, + startOfYear, + subDays, + subHours, + subMinutes, + subMonths, + subWeeks, + subYears, +} from 'date-fns'; +import { utcToZonedTime } from 'date-fns-tz'; +import { getDateLocale } from '@/lib/lang'; +import type { DateRange } from '@/lib/types'; + +export const TIME_UNIT = { + minute: 'minute', + hour: 'hour', + day: 'day', + week: 'week', + month: 'month', + year: 'year', +}; + +export const DATE_FUNCTIONS = { + minute: { + diff: differenceInMinutes, + add: addMinutes, + sub: subMinutes, + start: startOfMinute, + end: endOfMinute, + }, + hour: { + diff: differenceInHours, + add: addHours, + sub: subHours, + start: startOfHour, + end: endOfHour, + }, + day: { + diff: differenceInCalendarDays, + add: addDays, + sub: subDays, + start: startOfDay, + end: endOfDay, + }, + week: { + diff: differenceInCalendarWeeks, + add: addWeeks, + sub: subWeeks, + start: startOfWeek, + end: endOfWeek, + }, + month: { + diff: differenceInCalendarMonths, + add: addMonths, + sub: subMonths, + start: startOfMonth, + end: endOfMonth, + }, + year: { + diff: differenceInCalendarYears, + add: addYears, + sub: subYears, + start: startOfYear, + end: endOfYear, + }, +}; + +export const DATE_FORMATS = { + minute: 'yyyy-MM-dd HH:mm', + hour: 'yyyy-MM-dd HH', + day: 'yyyy-MM-dd', + week: "yyyy-'W'II", + month: 'yyyy-MM', + year: 'yyyy', +}; + +const TIMEZONE_MAPPINGS: Record<string, string> = { + 'Asia/Calcutta': 'Asia/Kolkata', +}; + +export function normalizeTimezone(timezone: string): string { + return TIMEZONE_MAPPINGS[timezone] || timezone; +} + +export function isValidTimezone(timezone: string) { + try { + const normalizedTimezone = normalizeTimezone(timezone); + Intl.DateTimeFormat(undefined, { timeZone: normalizedTimezone }); + return true; + } catch { + return false; + } +} + +export function getTimezone() { + return Intl.DateTimeFormat().resolvedOptions().timeZone; +} + +export function parseDateValue(value: string) { + const match = value.match?.(/^(?<num>[0-9-]+)(?<unit>hour|day|week|month|year)$/); + + if (!match) return null; + + const { num, unit } = match.groups; + + return { num: +num, unit }; +} + +export function parseDateRange(value: string, locale = 'en-US', timezone?: string): DateRange { + if (typeof value !== 'string') { + return null; + } + + if (value.startsWith('range')) { + const [, startTime, endTime] = value.split(':'); + + const startDate = new Date(+startTime); + const endDate = new Date(+endTime); + const unit = getMinimumUnit(startDate, endDate); + + return { + startDate, + endDate, + value, + ...parseDateValue(value), + unit, + }; + } + + const date = new Date(); + const now = timezone ? utcToZonedTime(date, timezone) : date; + const dateLocale = getDateLocale(locale); + const { num = 1, unit } = parseDateValue(value); + + switch (unit) { + case 'hour': + return { + startDate: num ? subHours(startOfHour(now), num) : startOfHour(now), + endDate: endOfHour(now), + offset: 0, + num: num || 1, + unit, + value, + }; + case 'day': + return { + startDate: num ? subDays(startOfDay(now), num) : startOfDay(now), + endDate: endOfDay(now), + unit: num ? 'day' : 'hour', + offset: 0, + num: num || 1, + value, + }; + case 'week': + return { + startDate: num + ? subWeeks(startOfWeek(now, { locale: dateLocale }), num) + : startOfWeek(now, { locale: dateLocale }), + endDate: endOfWeek(now, { locale: dateLocale }), + unit: 'day', + offset: 0, + num: num || 1, + value, + }; + case 'month': + return { + startDate: num ? subMonths(startOfMonth(now), num) : startOfMonth(now), + endDate: endOfMonth(now), + unit: num ? 'month' : 'day', + offset: 0, + num: num || 1, + value, + }; + case 'year': + return { + startDate: num ? subYears(startOfYear(now), num) : startOfYear(now), + endDate: endOfYear(now), + unit: 'month', + offset: 0, + num: num || 1, + value, + }; + } +} + +export function getOffsetDateRange(dateRange: DateRange, offset: number) { + if (offset === 0) { + return dateRange; + } + + const { startDate, endDate, unit, num, value } = dateRange; + + const change = num * offset; + const { add } = DATE_FUNCTIONS[unit]; + const { unit: originalUnit } = parseDateValue(value) || {}; + + switch (originalUnit) { + case 'day': + return { + ...dateRange, + offset, + startDate: addDays(startDate, change), + endDate: addDays(endDate, change), + }; + case 'week': + return { + ...dateRange, + offset, + startDate: addWeeks(startDate, change), + endDate: addWeeks(endDate, change), + }; + case 'month': + return { + ...dateRange, + offset, + startDate: addMonths(startDate, change), + endDate: addMonths(endDate, change), + }; + case 'year': + return { + ...dateRange, + offset, + startDate: addYears(startDate, change), + endDate: addYears(endDate, change), + }; + default: + return { + startDate: add(startDate, change), + endDate: add(endDate, change), + offset, + value, + unit, + num, + }; + } +} + +export function getAllowedUnits(startDate: Date, endDate: Date) { + const units = ['minute', 'hour', 'day', 'month', 'year']; + const minUnit = getMinimumUnit(startDate, endDate); + const index = units.indexOf(minUnit === 'year' ? 'month' : minUnit); + + return index >= 0 ? units.splice(index) : []; +} + +export function getMinimumUnit(startDate: number | Date, endDate: number | Date) { + if (differenceInMinutes(endDate, startDate) <= 60) { + return 'minute'; + } else if (differenceInHours(endDate, startDate) <= 48) { + return 'hour'; + } else if (differenceInCalendarMonths(endDate, startDate) <= 6) { + return 'day'; + } else if (differenceInCalendarMonths(endDate, startDate) <= 24) { + return 'month'; + } + + return 'year'; +} + +export function maxDate(...args: Date[]) { + return max(args.filter(n => isDate(n))); +} + +export function minDate(...args: any[]) { + return min(args.filter(n => isDate(n))); +} + +export function getCompareDate(compare: string, startDate: Date, endDate: Date) { + if (compare === 'yoy') { + return { compare, startDate: subYears(startDate, 1), endDate: subYears(endDate, 1) }; + } + + if (compare === 'prev') { + const diff = differenceInMinutes(endDate, startDate); + + return { compare, startDate: subMinutes(startDate, diff), endDate: subMinutes(endDate, diff) }; + } + + return {}; +} + +export function getDayOfWeekAsDate(dayOfWeek: number) { + const startOfWeekDay = startOfWeek(new Date()); + const daysToAdd = [0, 1, 2, 3, 4, 5, 6].indexOf(dayOfWeek); + let currentDate = addDays(startOfWeekDay, daysToAdd); + + // Ensure we're not returning a past date + if (isSameDay(currentDate, startOfWeekDay)) { + currentDate = addDays(currentDate, 7); + } + + return currentDate; +} + +export function formatDate( + date: string | number | Date, + dateFormat: string = 'PPpp', + locale = 'en-US', +) { + return format(typeof date === 'string' ? new Date(date) : date, dateFormat, { + locale: getDateLocale(locale), + }); +} + +export function generateTimeSeries( + data: { x: string; y: number; d?: string }[], + minDate: Date, + maxDate: Date, + unit: string, + locale: string, +) { + const add = DATE_FUNCTIONS[unit].add; + const start = DATE_FUNCTIONS[unit].start; + const fmt = DATE_FORMATS[unit]; + + let current = start(minDate); + const end = start(maxDate); + + const timeseries: string[] = []; + + while (isBefore(current, end) || isEqual(current, end)) { + timeseries.push(formatDate(current, fmt, locale)); + current = add(current, 1); + } + + const lookup = new Map(data.map(({ x, y, d }) => [formatDate(x, fmt, locale), { x, y, d }])); + + return timeseries.map(t => { + const { x, y, d } = lookup.get(t) || {}; + + return { x: t, d: d ?? x, y: y ?? null }; + }); +} + +export function getDateRangeValue(startDate: Date, endDate: Date) { + return `range:${startDate.getTime()}:${endDate.getTime()}`; +} + +export function getMonthDateRangeValue(date: Date) { + return getDateRangeValue(startOfMonth(date), endOfMonth(date)); +} + +export function isInvalidDate(date: any) { + return date instanceof Date && Number.isNaN(date.getTime()); +} diff --git a/src/lib/db.ts b/src/lib/db.ts new file mode 100644 index 0000000..7b6e836 --- /dev/null +++ b/src/lib/db.ts @@ -0,0 +1,40 @@ +export const PRISMA = 'prisma'; +export const POSTGRESQL = 'postgresql'; +export const CLICKHOUSE = 'clickhouse'; +export const KAFKA = 'kafka'; +export const KAFKA_PRODUCER = 'kafka-producer'; + +// Fixes issue with converting bigint values +BigInt.prototype.toJSON = function () { + return Number(this); +}; + +export function getDatabaseType(url = process.env.DATABASE_URL) { + const type = url?.split(':')[0]; + + if (type === 'postgres') { + return POSTGRESQL; + } + + return type; +} + +export async function runQuery(queries: any) { + if (process.env.CLICKHOUSE_URL) { + if (queries[KAFKA]) { + return queries[KAFKA](); + } + + return queries[CLICKHOUSE](); + } + + const db = getDatabaseType(); + + if (db === POSTGRESQL) { + return queries[PRISMA](); + } +} + +export function notImplemented() { + throw new Error('Not implemented.'); +} diff --git a/src/lib/detect.ts b/src/lib/detect.ts new file mode 100644 index 0000000..68cb667 --- /dev/null +++ b/src/lib/detect.ts @@ -0,0 +1,154 @@ +import path from 'node:path'; +import { browserName, detectOS } from 'detect-browser'; +import ipaddr from 'ipaddr.js'; +import isLocalhost from 'is-localhost-ip'; +import maxmind from 'maxmind'; +import { UAParser } from 'ua-parser-js'; +import { getIpAddress, stripPort } from '@/lib/ip'; +import { safeDecodeURIComponent } from '@/lib/url'; + +const MAXMIND = 'maxmind'; + +const PROVIDER_HEADERS = [ + // Cloudflare headers + { + countryHeader: 'cf-ipcountry', + regionHeader: 'cf-region-code', + cityHeader: 'cf-ipcity', + }, + // Vercel headers + { + countryHeader: 'x-vercel-ip-country', + regionHeader: 'x-vercel-ip-country-region', + cityHeader: 'x-vercel-ip-city', + }, + // CloudFront headers + { + countryHeader: 'cloudfront-viewer-country', + regionHeader: 'cloudfront-viewer-country-region', + cityHeader: 'cloudfront-viewer-city', + }, +]; + +export function getDevice(userAgent: string, screen: string = '') { + const { device } = UAParser(userAgent); + + const [width] = screen.split('x'); + + const type = device?.type || 'desktop'; + + if (type === 'desktop' && screen && +width <= 1920) { + return 'laptop'; + } + + return type; +} + +function getRegionCode(country: string, region: string) { + if (!country || !region) { + return undefined; + } + + return region.includes('-') ? region : `${country}-${region}`; +} + +function decodeHeader(s: string | undefined | null): string | undefined | null { + if (s === undefined || s === null) { + return s; + } + + return Buffer.from(s, 'latin1').toString('utf-8'); +} + +export async function getLocation(ip: string = '', headers: Headers, hasPayloadIP: boolean) { + // Ignore local ips + if (!ip || (await isLocalhost(ip))) { + return null; + } + + if (!hasPayloadIP && !process.env.SKIP_LOCATION_HEADERS) { + for (const provider of PROVIDER_HEADERS) { + const countryHeader = headers.get(provider.countryHeader); + if (countryHeader) { + const country = decodeHeader(countryHeader); + const region = decodeHeader(headers.get(provider.regionHeader)); + const city = decodeHeader(headers.get(provider.cityHeader)); + + return { + country, + region: getRegionCode(country, region), + city, + }; + } + } + } + + // Database lookup + if (!globalThis[MAXMIND]) { + const dir = path.join(process.cwd(), 'geo'); + + globalThis[MAXMIND] = await maxmind.open( + process.env.GEOLITE_DB_PATH || path.resolve(dir, 'GeoLite2-City.mmdb'), + ); + } + + const result = globalThis[MAXMIND]?.get(stripPort(ip)); + + if (result) { + const country = result.country?.iso_code ?? result?.registered_country?.iso_code; + const region = result.subdivisions?.[0]?.iso_code; + const city = result.city?.names?.en; + + return { + country, + region: getRegionCode(country, region), + city, + }; + } +} + +export async function getClientInfo(request: Request, payload: Record<string, any>) { + const userAgent = payload?.userAgent || request.headers.get('user-agent'); + const ip = payload?.ip || getIpAddress(request.headers); + const location = await getLocation(ip, request.headers, !!payload?.ip); + const country = safeDecodeURIComponent(location?.country); + const region = safeDecodeURIComponent(location?.region); + const city = safeDecodeURIComponent(location?.city); + const browser = payload?.browser ?? browserName(userAgent); + const os = payload?.os ?? (detectOS(userAgent) as string); + const device = payload?.device ?? getDevice(userAgent, payload?.screen); + + return { userAgent, browser, os, ip, country, region, city, device }; +} + +export function hasBlockedIp(clientIp: string) { + const ignoreIps = process.env.IGNORE_IP; + + if (ignoreIps) { + const ips = []; + + if (ignoreIps) { + ips.push(...ignoreIps.split(',').map(n => n.trim())); + } + + return ips.find(ip => { + if (ip === clientIp) { + return true; + } + + // CIDR notation + if (ip.indexOf('/') > 0) { + const addr = ipaddr.parse(clientIp); + const range = ipaddr.parseCIDR(ip); + + if (addr.kind() === range[0].kind() && addr.match(range)) { + return true; + } + } + + return false; + }); + } + + return false; +} diff --git a/src/lib/fetch.ts b/src/lib/fetch.ts new file mode 100644 index 0000000..1086973 --- /dev/null +++ b/src/lib/fetch.ts @@ -0,0 +1,58 @@ +import { buildPath } from '@/lib/url'; + +export interface ErrorResponse { + error: { + status: number; + message: string; + code?: string; + }; +} + +export interface FetchResponse { + ok: boolean; + status: number; + data?: any; + error?: ErrorResponse; +} + +export async function request( + method: string, + url: string, + body?: string, + headers: object = {}, +): Promise<FetchResponse> { + return fetch(url, { + method, + cache: 'no-cache', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...headers, + }, + body, + }).then(async res => { + const data = await res.json(); + + return { + ok: res.ok, + status: res.status, + data, + }; + }); +} + +export async function httpGet(path: string, params: object = {}, headers: object = {}) { + return request('GET', buildPath(path, params), undefined, headers); +} + +export async function httpDelete(path: string, params: object = {}, headers: object = {}) { + return request('DELETE', buildPath(path, params), undefined, headers); +} + +export async function httpPost(path: string, params: object = {}, headers: object = {}) { + return request('POST', path, JSON.stringify(params), headers); +} + +export async function httpPut(path: string, params: object = {}, headers: object = {}) { + return request('PUT', path, JSON.stringify(params), headers); +} diff --git a/src/lib/filters.ts b/src/lib/filters.ts new file mode 100644 index 0000000..3da268d --- /dev/null +++ b/src/lib/filters.ts @@ -0,0 +1,31 @@ +export const percentFilter = (data: any[]) => { + if (!Array.isArray(data)) return []; + const total = data.reduce((n, { y }) => n + y, 0); + return data.map(({ x, y, ...props }) => ({ x, y, z: total ? (y / total) * 100 : 0, ...props })); +}; + +export const paramFilter = (data: any[]) => { + const map = data.reduce((obj, { x, y }) => { + try { + const searchParams = new URLSearchParams(x); + + for (const [key, value] of searchParams) { + if (!obj[key]) { + obj[key] = { [value]: y }; + } else if (!obj[key][value]) { + obj[key][value] = y; + } else { + obj[key][value] += y; + } + } + } catch { + // Ignore + } + + return obj; + }, {}); + + return Object.keys(map).flatMap(key => + Object.keys(map[key]).map(n => ({ x: `${key}=${n}`, p: key, v: n, y: map[key][n] })), + ); +}; diff --git a/src/lib/format.ts b/src/lib/format.ts new file mode 100644 index 0000000..52fd304 --- /dev/null +++ b/src/lib/format.ts @@ -0,0 +1,118 @@ +export function parseTime(val: number) { + const days = ~~(val / 86400); + const hours = ~~(val / 3600) - days * 24; + const minutes = ~~(val / 60) - days * 1440 - hours * 60; + const seconds = ~~val - days * 86400 - hours * 3600 - minutes * 60; + const ms = (val - ~~val) * 1000; + + return { + days, + hours, + minutes, + seconds, + ms, + }; +} + +export function formatTime(val: number) { + const { hours, minutes, seconds } = parseTime(val); + const h = hours > 0 ? `${hours}:` : ''; + const m = hours > 0 ? minutes.toString().padStart(2, '0') : minutes; + const s = seconds.toString().padStart(2, '0'); + + return `${h}${m}:${s}`; +} + +export function formatShortTime(val: number, formats = ['m', 's'], space = '') { + const { days, hours, minutes, seconds, ms } = parseTime(val); + let t = ''; + + if (days > 0 && formats.indexOf('d') !== -1) t += `${days}d${space}`; + if (hours > 0 && formats.indexOf('h') !== -1) t += `${hours}h${space}`; + if (minutes > 0 && formats.indexOf('m') !== -1) t += `${minutes}m${space}`; + if (seconds > 0 && formats.indexOf('s') !== -1) t += `${seconds}s${space}`; + if (ms > 0 && formats.indexOf('ms') !== -1) t += `${ms}ms`; + + if (!t) { + return `0${formats[formats.length - 1]}`; + } + + return t; +} + +export function formatNumber(n: string | number) { + return Number(n).toFixed(0); +} + +export function formatLongNumber(value: number) { + const n = Number(value); + + if (n >= 1000000000) { + return `${(n / 1000000).toFixed(1)}b`; + } + if (n >= 1000000) { + return `${(n / 1000000).toFixed(1)}m`; + } + if (n >= 100000) { + return `${(n / 1000).toFixed(0)}k`; + } + if (n >= 10000) { + return `${(n / 1000).toFixed(1)}k`; + } + if (n >= 1000) { + return `${(n / 1000).toFixed(2)}k`; + } + + return formatNumber(n); +} + +export function stringToColor(str: string) { + if (!str) { + return '#ffffff'; + } + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + let color = '#'; + for (let i = 0; i < 3; i++) { + const value = (hash >> (i * 8)) & 0xff; + color += `00${value.toString(16)}`.slice(-2); + } + return color; +} + +export function formatCurrency(value: number, currency: string, locale = 'en-US') { + let formattedValue: Intl.NumberFormat; + + try { + formattedValue = new Intl.NumberFormat(locale, { + style: 'currency', + currency: currency, + }); + } catch { + // Fallback to default currency format if an error occurs + formattedValue = new Intl.NumberFormat(locale, { + style: 'currency', + currency: 'USD', + }); + } + + return formattedValue.format(value); +} + +export function formatLongCurrency(value: number, currency: string, locale = 'en-US') { + const n = Number(value); + + if (n >= 1000000000) { + return `${formatCurrency(n / 1000000000, currency, locale)}b`; + } + if (n >= 1000000) { + return `${formatCurrency(n / 1000000, currency, locale)}m`; + } + if (n >= 1000) { + return `${formatCurrency(n / 1000, currency, locale)}k`; + } + + return formatCurrency(n, currency, locale); +} diff --git a/src/lib/generate.ts b/src/lib/generate.ts new file mode 100644 index 0000000..8e25aa0 --- /dev/null +++ b/src/lib/generate.ts @@ -0,0 +1,20 @@ +import prand from 'pure-rand'; + +const seed = Date.now() ^ (Math.random() * 0x100000000); +const rng = prand.xoroshiro128plus(seed); + +export function random(min: number, max: number) { + return prand.unsafeUniformIntDistribution(min, max, rng); +} + +export function getRandomChars( + n: number, + chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', +) { + const arr = chars.split(''); + let s = ''; + for (let i = 0; i < n; i++) { + s += arr[random(0, arr.length - 1)]; + } + return s; +} diff --git a/src/lib/ip.ts b/src/lib/ip.ts new file mode 100644 index 0000000..5cd7757 --- /dev/null +++ b/src/lib/ip.ts @@ -0,0 +1,60 @@ +export const IP_ADDRESS_HEADERS = [ + 'true-client-ip', // CDN + 'cf-connecting-ip', // Cloudflare + 'fastly-client-ip', // Fastly + 'x-nf-client-connection-ip', // Netlify + 'do-connecting-ip', // Digital Ocean + 'x-real-ip', // Reverse proxy + 'x-appengine-user-ip', // Google App Engine + 'x-forwarded-for', + 'forwarded', + 'x-client-ip', + 'x-cluster-client-ip', + 'x-forwarded', +]; + +export function getIpAddress(headers: Headers) { + const customHeader = process.env.CLIENT_IP_HEADER; + + if (customHeader && headers.get(customHeader)) { + return headers.get(customHeader); + } + + const header = IP_ADDRESS_HEADERS.find(name => { + return headers.get(name); + }); + + const ip = headers.get(header); + + if (header === 'x-forwarded-for') { + return ip?.split(',')?.[0]?.trim(); + } + + if (header === 'forwarded') { + const match = ip.match(/for=(\[?[0-9a-fA-F:.]+\]?)/); + + if (match) { + return match[1]; + } + } + + return ip; +} + +export function stripPort(ip: string) { + if (ip.startsWith('[')) { + const endBracket = ip.indexOf(']'); + if (endBracket !== -1) { + return ip.slice(0, endBracket + 1); + } + } + + const idx = ip.lastIndexOf(':'); + if (idx !== -1) { + if (ip.includes('.') || /^[a-zA-Z0-9.-]+$/.test(ip.slice(0, idx))) { + return ip.slice(0, idx); + } + } + + return ip; +} diff --git a/src/lib/jwt.ts b/src/lib/jwt.ts new file mode 100644 index 0000000..470c48f --- /dev/null +++ b/src/lib/jwt.ts @@ -0,0 +1,36 @@ +import jwt from 'jsonwebtoken'; +import { decrypt, encrypt } from '@/lib/crypto'; + +export function createToken(payload: any, secret: any, options?: any) { + return jwt.sign(payload, secret, options); +} + +export function parseToken(token: string, secret: any) { + try { + return jwt.verify(token, secret); + } catch { + return null; + } +} + +export function createSecureToken(payload: any, secret: any, options?: any) { + return encrypt(createToken(payload, secret, options), secret); +} + +export function parseSecureToken(token: string, secret: any) { + try { + return jwt.verify(decrypt(token, secret), secret); + } catch { + return null; + } +} + +export async function parseAuthToken(req: Request, secret: string) { + try { + const token = req.headers.get('authorization')?.split(' ')?.[1]; + + return parseSecureToken(token as string, secret); + } catch { + return null; + } +} diff --git a/src/lib/kafka.ts b/src/lib/kafka.ts new file mode 100644 index 0000000..1d60e1f --- /dev/null +++ b/src/lib/kafka.ts @@ -0,0 +1,112 @@ +import type * as tls from 'node:tls'; +import debug from 'debug'; +import { Kafka, logLevel, type Producer, type RecordMetadata, type SASLOptions } from 'kafkajs'; +import { serializeError } from 'serialize-error'; +import { KAFKA, KAFKA_PRODUCER } from '@/lib/db'; + +const log = debug('umami:kafka'); +const CONNECT_TIMEOUT = 5000; +const SEND_TIMEOUT = 3000; +const ACKS = 1; + +let kafka: Kafka; +let producer: Producer; +const enabled = Boolean(process.env.KAFKA_URL && process.env.KAFKA_BROKER); + +function getClient() { + const { username, password } = new URL(process.env.KAFKA_URL); + const brokers = process.env.KAFKA_BROKER.split(','); + const mechanism = + (process.env.KAFKA_SASL_MECHANISM as 'plain' | 'scram-sha-256' | 'scram-sha-512') || 'plain'; + + const ssl: { ssl?: tls.ConnectionOptions | boolean; sasl?: SASLOptions } = + username && password + ? { + ssl: { + rejectUnauthorized: false, + }, + sasl: { + mechanism, + username, + password, + }, + } + : {}; + + const client: Kafka = new Kafka({ + clientId: 'umami', + brokers: brokers, + connectionTimeout: CONNECT_TIMEOUT, + logLevel: logLevel.ERROR, + ...ssl, + }); + + if (process.env.NODE_ENV !== 'production') { + globalThis[KAFKA] = client; + } + + log('Kafka initialized'); + + return client; +} + +async function getProducer(): Promise<Producer> { + const producer = kafka.producer(); + await producer.connect(); + + if (process.env.NODE_ENV !== 'production') { + globalThis[KAFKA_PRODUCER] = producer; + } + + log('Kafka producer initialized'); + + return producer; +} + +async function sendMessage( + topic: string, + message: Record<string, string | number> | Record<string, string | number>[], +): Promise<RecordMetadata[]> { + try { + await connect(); + + return producer.send({ + topic, + messages: Array.isArray(message) + ? message.map(a => { + return { value: JSON.stringify(a) }; + }) + : [ + { + value: JSON.stringify(message), + }, + ], + timeout: SEND_TIMEOUT, + acks: ACKS, + }); + } catch (e) { + // eslint-disable-next-line no-console + console.log('KAFKA ERROR:', serializeError(e)); + } +} + +async function connect(): Promise<Kafka> { + if (!kafka) { + kafka = process.env.KAFKA_URL && process.env.KAFKA_BROKER && (globalThis[KAFKA] || getClient()); + + if (kafka) { + producer = globalThis[KAFKA_PRODUCER] || (await getProducer()); + } + } + + return kafka; +} + +export default { + enabled, + client: kafka, + producer, + log, + connect, + sendMessage, +}; diff --git a/src/lib/lang.ts b/src/lib/lang.ts new file mode 100644 index 0000000..f874640 --- /dev/null +++ b/src/lib/lang.ts @@ -0,0 +1,111 @@ +import { + arSA, + be, + bg, + bn, + bs, + ca, + cs, + da, + de, + el, + enGB, + enUS, + es, + faIR, + fi, + fr, + he, + hi, + hr, + hu, + id, + it, + ja, + km, + ko, + lt, + mn, + ms, + nb, + nl, + pl, + pt, + ptBR, + ro, + ru, + sk, + sl, + sv, + ta, + th, + tr, + uk, + uz, + vi, + zhCN, + zhTW, +} from 'date-fns/locale'; + +export const languages = { + 'ar-SA': { label: 'العربية', dateLocale: arSA, dir: 'rtl' }, + 'be-BY': { label: 'Беларуская', dateLocale: be }, + 'bg-BG': { label: 'български език', dateLocale: bg }, + 'bn-BD': { label: 'বাংলা', dateLocale: bn }, + 'bs-BA': { label: 'Bosanski', dateLocale: bs }, + 'ca-ES': { label: 'Català', dateLocale: ca }, + 'cs-CZ': { label: 'Čeština', dateLocale: cs }, + 'da-DK': { label: 'Dansk', dateLocale: da }, + 'de-CH': { label: 'Schwiizerdütsch', dateLocale: de }, + 'de-DE': { label: 'Deutsch', dateLocale: de }, + 'el-GR': { label: 'Ελληνικά', dateLocale: el }, + 'en-GB': { label: 'English (UK)', dateLocale: enGB }, + 'en-US': { label: 'English (US)', dateLocale: enUS }, + 'es-ES': { label: 'Español', dateLocale: es }, + 'fa-IR': { label: 'فارسی', dateLocale: faIR, dir: 'rtl' }, + 'fi-FI': { label: 'Suomi', dateLocale: fi }, + 'fo-FO': { label: 'Føroyskt' }, + 'fr-FR': { label: 'Français', dateLocale: fr }, + 'ga-ES': { label: 'Galacian (Spain)', dateLocale: es }, + 'he-IL': { label: 'עברית', dateLocale: he }, + 'hi-IN': { label: 'हिन्दी', dateLocale: hi }, + 'hr-HR': { label: 'Hrvatski', dateLocale: hr }, + 'hu-HU': { label: 'Hungarian', dateLocale: hu }, + 'id-ID': { label: 'Bahasa Indonesia', dateLocale: id }, + 'it-IT': { label: 'Italiano', dateLocale: it }, + 'ja-JP': { label: '日本語', dateLocale: ja }, + 'km-KH': { label: 'ភាសាខ្មែរ', dateLocale: km }, + 'ko-KR': { label: '한국어', dateLocale: ko }, + 'lt-LT': { label: 'Lietuvių', dateLocale: lt }, + 'mn-MN': { label: 'Монгол', dateLocale: mn }, + 'ms-MY': { label: 'Malay', dateLocale: ms }, + 'my-MM': { label: 'မြန်မာဘာသာ', dateLocale: enUS }, + 'nl-NL': { label: 'Nederlands', dateLocale: nl }, + 'nb-NO': { label: 'Norsk Bokmål', dateLocale: nb }, + 'pl-PL': { label: 'Polski', dateLocale: pl }, + 'pt-BR': { label: 'Português do Brasil', dateLocale: ptBR }, + 'pt-PT': { label: 'Português', dateLocale: pt }, + 'ro-RO': { label: 'Română', dateLocale: ro }, + 'ru-RU': { label: 'Русский', dateLocale: ru }, + 'si-LK': { label: 'සිංහල', dateLocale: id }, + 'sk-SK': { label: 'Slovenčina', dateLocale: sk }, + 'sl-SI': { label: 'Slovenščina', dateLocale: sl }, + 'sv-SE': { label: 'Svenska', dateLocale: sv }, + 'ta-IN': { label: 'தமிழ்', dateLocale: ta }, + 'th-TH': { label: 'ภาษาไทย', dateLocale: th }, + 'tr-TR': { label: 'Türkçe', dateLocale: tr }, + 'uk-UA': { label: 'українська', dateLocale: uk }, + 'ur-PK': { label: 'Urdu (Pakistan)', dateLocale: uk, dir: 'rtl' }, + 'uz-UZ': { label: 'O‘zbekcha', dateLocale: uz }, + 'vi-VN': { label: 'Tiếng Việt', dateLocale: vi }, + 'zh-CN': { label: '中文', dateLocale: zhCN }, + 'zh-TW': { label: '中文(繁體)', dateLocale: zhTW }, +}; + +export function getDateLocale(locale: string) { + return languages[locale]?.dateLocale || enUS; +} + +export function getTextDirection(locale: string) { + return languages[locale]?.dir || 'ltr'; +} diff --git a/src/lib/load.ts b/src/lib/load.ts new file mode 100644 index 0000000..d4d6c3c --- /dev/null +++ b/src/lib/load.ts @@ -0,0 +1,40 @@ +import type { Session, Website } from '@/generated/prisma/client'; +import redis from '@/lib/redis'; +import { getWebsite } from '@/queries/prisma'; +import { getWebsiteSession } from '@/queries/sql'; + +export async function fetchWebsite(websiteId: string): Promise<Website> { + let website = null; + + if (redis.enabled) { + website = await redis.client.fetch(`website:${websiteId}`, () => getWebsite(websiteId), 86400); + } else { + website = await getWebsite(websiteId); + } + + if (!website || website.deletedAt) { + return null; + } + + return website; +} + +export async function fetchSession(websiteId: string, sessionId: string): Promise<Session> { + let session = null; + + if (redis.enabled) { + session = await redis.client.fetch( + `session:${sessionId}`, + () => getWebsiteSession(websiteId, sessionId), + 86400, + ); + } else { + session = await getWebsiteSession(websiteId, sessionId); + } + + if (!session) { + return null; + } + + return session; +} diff --git a/src/lib/params.ts b/src/lib/params.ts new file mode 100644 index 0000000..ab2d586 --- /dev/null +++ b/src/lib/params.ts @@ -0,0 +1,62 @@ +import { FILTER_COLUMNS, OPERATORS } from '@/lib/constants'; +import type { Filter, QueryFilters, QueryOptions } from '@/lib/types'; + +export function parseFilterValue(param: any) { + if (typeof param === 'string') { + const operatorValues = Object.values(OPERATORS).join('|'); + + const regex = new RegExp(`^(${operatorValues})\\.(.*)$`); + + const [, operator, value] = param.match(regex) || []; + + return { operator: operator || OPERATORS.equals, value: value || param }; + } + + return { operator: OPERATORS.equals, value: param }; +} + +export function isEqualsOperator(operator: any) { + return [OPERATORS.equals, OPERATORS.notEquals].includes(operator); +} + +export function isSearchOperator(operator: any) { + return [OPERATORS.contains, OPERATORS.doesNotContain].includes(operator); +} + +export function filtersObjectToArray(filters: QueryFilters, options: QueryOptions = {}): Filter[] { + if (!filters) { + return []; + } + + return Object.keys(filters).reduce((arr, key) => { + const filter = filters[key]; + + if (filter === undefined || filter === null) { + return arr; + } + + if (filter?.name && filter?.value !== undefined) { + return arr.concat({ ...filter, column: options?.columns?.[key] ?? FILTER_COLUMNS[key] }); + } + + const { operator, value } = parseFilterValue(filter); + + return arr.concat({ + name: key, + column: options?.columns?.[key] ?? FILTER_COLUMNS[key], + operator, + value, + prefix: options?.prefix, + }); + }, []); +} + +export function filtersArrayToObject(filters: Filter[]) { + return filters.reduce((obj, filter: Filter) => { + const { name, operator, value } = filter; + + obj[name] = `${operator}.${value}`; + + return obj; + }, {}); +} diff --git a/src/lib/password.ts b/src/lib/password.ts new file mode 100644 index 0000000..f5c450b --- /dev/null +++ b/src/lib/password.ts @@ -0,0 +1,11 @@ +import bcrypt from 'bcryptjs'; + +const SALT_ROUNDS = 10; + +export function hashPassword(password: string, rounds = SALT_ROUNDS) { + return bcrypt.hashSync(password, rounds); +} + +export function checkPassword(password: string, passwordHash: string) { + return bcrypt.compareSync(password, passwordHash); +} diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts new file mode 100644 index 0000000..64cb870 --- /dev/null +++ b/src/lib/prisma.ts @@ -0,0 +1,368 @@ +import { PrismaPg } from '@prisma/adapter-pg'; +import { readReplicas } from '@prisma/extension-read-replicas'; +import debug from 'debug'; +import { PrismaClient } from '@/generated/prisma/client'; +import { DEFAULT_PAGE_SIZE, FILTER_COLUMNS, OPERATORS, SESSION_COLUMNS } from './constants'; +import { filtersObjectToArray } from './params'; +import type { Operator, QueryFilters, QueryOptions } from './types'; + +const log = debug('umami:prisma'); + +const PRISMA = 'prisma'; + +const PRISMA_LOG_OPTIONS = { + log: [ + { + emit: 'event' as const, + level: 'query' as const, + }, + ], +}; + +const DATE_FORMATS = { + minute: 'YYYY-MM-DD HH24:MI:00', + hour: 'YYYY-MM-DD HH24:00:00', + day: 'YYYY-MM-DD HH24:00:00', + month: 'YYYY-MM-01 HH24:00:00', + year: 'YYYY-01-01 HH24:00:00', +}; + +const DATE_FORMATS_UTC = { + minute: 'YYYY-MM-DD"T"HH24:MI:00"Z"', + hour: 'YYYY-MM-DD"T"HH24:00:00"Z"', + day: 'YYYY-MM-DD"T"HH24:00:00"Z"', + month: 'YYYY-MM-01"T"HH24:00:00"Z"', + year: 'YYYY-01-01"T"HH24:00:00"Z"', +}; + +function getAddIntervalQuery(field: string, interval: string): string { + return `${field} + interval '${interval}'`; +} + +function getDayDiffQuery(field1: string, field2: string): string { + return `${field1}::date - ${field2}::date`; +} + +function getCastColumnQuery(field: string, type: string): string { + return `${field}::${type}`; +} + +function getDateSQL(field: string, unit: string, timezone?: string): string { + if (timezone && timezone !== 'utc') { + return `to_char(date_trunc('${unit}', ${field} at time zone '${timezone}'), '${DATE_FORMATS[unit]}')`; + } + + return `to_char(date_trunc('${unit}', ${field}), '${DATE_FORMATS_UTC[unit]}')`; +} + +function getDateWeeklySQL(field: string, timezone?: string) { + return `concat(extract(dow from (${field} at time zone '${timezone}')), ':', to_char((${field} at time zone '${timezone}'), 'HH24'))`; +} + +export function getTimestampSQL(field: string) { + return `floor(extract(epoch from ${field}))`; +} + +function getTimestampDiffSQL(field1: string, field2: string): string { + return `floor(extract(epoch from (${field2} - ${field1})))`; +} + +function getSearchSQL(column: string, param: string = 'search'): string { + return `and ${column} ilike {{${param}}}`; +} + +function mapFilter(column: string, operator: string, name: string, type: string = '') { + const value = `{{${name}${type ? `::${type}` : ''}}}`; + + switch (operator) { + case OPERATORS.equals: + return `${column} = ${value}`; + case OPERATORS.notEquals: + return `${column} != ${value}`; + case OPERATORS.contains: + return `${column} ilike ${value}`; + case OPERATORS.doesNotContain: + return `${column} not ilike ${value}`; + default: + return ''; + } +} + +function getFilterQuery(filters: Record<string, any>, options: QueryOptions = {}): string { + const query = filtersObjectToArray(filters, options).reduce( + (arr, { name, column, operator, prefix = '' }) => { + const isCohort = options?.isCohort; + + if (isCohort) { + column = FILTER_COLUMNS[name.slice('cohort_'.length)]; + } + + if (column) { + arr.push(`and ${mapFilter(`${prefix}${column}`, operator, name)}`); + + if (name === 'referrer') { + arr.push( + `and (website_event.referrer_domain != website_event.hostname or website_event.referrer_domain is null)`, + ); + } + } + + return arr; + }, + [], + ); + + return query.join('\n'); +} + +function getCohortQuery(filters: QueryFilters = {}) { + if (!filters || Object.keys(filters).length === 0) { + return ''; + } + + const filterQuery = getFilterQuery(filters, { isCohort: true }); + + return `join + (select distinct website_event.session_id + from website_event + join session on session.session_id = website_event.session_id + and session.website_id = website_event.website_id + where website_event.website_id = {{websiteId}} + and website_event.created_at between {{cohort_startDate}} and {{cohort_endDate}} + ${filterQuery} + ) cohort + on cohort.session_id = website_event.session_id + `; +} + +function getDateQuery(filters: Record<string, any>) { + const { startDate, endDate } = filters; + + if (startDate) { + if (endDate) { + return `and website_event.created_at between {{startDate}} and {{endDate}}`; + } else { + return `and website_event.created_at >= {{startDate}}`; + } + } + + return ''; +} + +function getQueryParams(filters: Record<string, any>) { + return { + ...filters, + ...filtersObjectToArray(filters).reduce((obj, { name, operator, value }) => { + obj[name] = ([OPERATORS.contains, OPERATORS.doesNotContain] as Operator[]).includes(operator) + ? `%${value}%` + : value; + + return obj; + }, {}), + }; +} + +function parseFilters(filters: Record<string, any>, options?: QueryOptions) { + const joinSession = Object.keys(filters).find(key => + ['referrer', ...SESSION_COLUMNS].includes(key), + ); + + const cohortFilters = Object.fromEntries( + Object.entries(filters).filter(([key]) => key.startsWith('cohort_')), + ); + + return { + joinSessionQuery: + options?.joinSession || joinSession + ? `inner join session on website_event.session_id = session.session_id and website_event.website_id = session.website_id` + : '', + dateQuery: getDateQuery(filters), + filterQuery: getFilterQuery(filters, options), + queryParams: getQueryParams(filters), + cohortQuery: getCohortQuery(cohortFilters), + }; +} + +async function rawQuery(sql: string, data: Record<string, any>, name?: string): Promise<any> { + if (process.env.LOG_QUERY) { + log('QUERY:\n', sql); + log('PARAMETERS:\n', data); + log('NAME:\n', name); + } + const params = []; + const schema = getSchema(); + + if (schema) { + await client.$executeRawUnsafe(`SET search_path TO "${schema}";`); + } + + const query = sql?.replaceAll(/\{\{\s*(\w+)(::\w+)?\s*}}/g, (...args) => { + const [, name, type] = args; + + const value = data[name]; + + params.push(value); + + return `$${params.length}${type ?? ''}`; + }); + + if (process.env.DATABASE_REPLICA_URL && '$replica' in client) { + return client.$replica().$queryRawUnsafe(query, ...params); + } + + return client.$queryRawUnsafe(query, ...params); +} + +async function pagedQuery<T>(model: string, criteria: T, filters?: QueryFilters) { + const { page = 1, pageSize, orderBy, sortDescending = false, search } = filters || {}; + const size = +pageSize || DEFAULT_PAGE_SIZE; + + const data = await client[model].findMany({ + ...criteria, + ...{ + ...(size > 0 && { take: +size, skip: +size * (+page - 1) }), + ...(orderBy && { + orderBy: [ + { + [orderBy]: sortDescending ? 'desc' : 'asc', + }, + ], + }), + }, + }); + + const count = await client[model].count({ where: (criteria as any).where }); + + return { data, count, page: +page, pageSize: size, orderBy, search }; +} + +async function pagedRawQuery( + query: string, + queryParams: Record<string, any>, + filters: QueryFilters, + name?: string, +) { + const { page = 1, pageSize, orderBy, sortDescending = false } = filters; + const size = +pageSize || DEFAULT_PAGE_SIZE; + const offset = +size * (+page - 1); + const direction = sortDescending ? 'desc' : 'asc'; + + const statements = [ + orderBy && `order by ${orderBy} ${direction}`, + +size > 0 && `limit ${+size} offset ${offset}`, + ] + .filter(n => n) + .join('\n'); + + const count = await rawQuery(`select count(*) as num from (${query}) t`, queryParams).then( + res => res[0].num, + ); + + const data = await rawQuery(`${query}${statements}`, queryParams, name); + + return { data, count, page: +page, pageSize: size, orderBy }; +} + +function getSearchParameters(query: string, filters: Record<string, any>[]) { + if (!query) return; + + const parseFilter = (filter: Record<string, any>) => { + const [[key, value]] = Object.entries(filter); + + return { + [key]: + typeof value === 'string' + ? { + [value]: query, + mode: 'insensitive', + } + : parseFilter(value), + }; + }; + + const params = filters.map(filter => parseFilter(filter)); + + return { + AND: { + OR: params, + }, + }; +} + +function transaction(input: any, options?: any) { + return client.$transaction(input, options); +} + +function getSchema() { + const connectionUrl = new URL(process.env.DATABASE_URL); + + return connectionUrl.searchParams.get('schema'); +} + +function getClient() { + const url = process.env.DATABASE_URL; + const replicaUrl = process.env.DATABASE_REPLICA_URL; + const logQuery = process.env.LOG_QUERY; + const schema = getSchema(); + + const baseAdapter = new PrismaPg({ connectionString: url }, { schema }); + + const baseClient = new PrismaClient({ + adapter: baseAdapter, + errorFormat: 'pretty', + ...(logQuery ? PRISMA_LOG_OPTIONS : {}), + }); + + if (logQuery) { + baseClient.$on('query', log); + } + + if (!replicaUrl) { + log('Prisma initialized'); + globalThis[PRISMA] ??= baseClient; + return baseClient; + } + + const replicaAdapter = new PrismaPg({ connectionString: replicaUrl }, { schema }); + + const replicaClient = new PrismaClient({ + adapter: replicaAdapter, + errorFormat: 'pretty', + ...(logQuery ? PRISMA_LOG_OPTIONS : {}), + }); + + if (logQuery) { + replicaClient.$on('query', log); + } + + const extended = baseClient.$extends( + readReplicas({ + replicas: [replicaClient], + }), + ); + + log('Prisma initialized (with replica)'); + globalThis[PRISMA] ??= extended; + + return extended; +} + +const client = (globalThis[PRISMA] || getClient()) as ReturnType<typeof getClient>; + +export default { + client, + transaction, + getAddIntervalQuery, + getCastColumnQuery, + getDayDiffQuery, + getDateSQL, + getDateWeeklySQL, + getFilterQuery, + getSearchParameters, + getTimestampDiffSQL, + getSearchSQL, + pagedQuery, + pagedRawQuery, + parseFilters, + rawQuery, +}; diff --git a/src/lib/react.ts b/src/lib/react.ts new file mode 100644 index 0000000..668cdf1 --- /dev/null +++ b/src/lib/react.ts @@ -0,0 +1,77 @@ +import { + Children, + cloneElement, + type FC, + Fragment, + isValidElement, + type ReactElement, + type ReactNode, +} from 'react'; + +export function getFragmentChildren(children: ReactNode) { + return (children as ReactElement)?.type === Fragment + ? (children as ReactElement).props.children + : children; +} + +export function isValidChild(child: ReactElement, types: FC | FC[]) { + if (!isValidElement(child)) { + return false; + } + return (Array.isArray(types) ? types : [types]).find(type => type === child.type); +} + +export function mapChildren( + children: ReactNode, + handler: (child: ReactElement, index: number) => any, +) { + return Children.map(getFragmentChildren(children) as ReactElement[], (child, index) => { + if (!child?.props) { + return null; + } + return handler(child, index); + }); +} + +export function cloneChildren( + children: ReactNode, + handler: (child: ReactElement, index: number) => any, + options?: { validChildren?: any[]; onlyRenderValid?: boolean }, +): ReactNode { + if (!children) { + return null; + } + + const { validChildren, onlyRenderValid = false } = options || {}; + + return mapChildren(children, (child, index) => { + const invalid = validChildren && !isValidChild(child as ReactElement, validChildren); + + if (onlyRenderValid && invalid) { + return null; + } + + if (!invalid && isValidElement(child)) { + return cloneElement(child, handler(child, index)); + } + + return child; + }); +} + +export function renderChildren( + children: ReactNode | ((item: any, index: number, array: any) => ReactNode), + items: any[], + handler: (child: ReactElement, index: number) => object | undefined, + options?: { validChildren?: any[]; onlyRenderValid?: boolean }, +): ReactNode { + if (typeof children === 'function' && items?.length > 0) { + return cloneChildren(items.map(children), handler, options); + } + + return cloneChildren(getFragmentChildren(children as ReactNode), handler, options); +} + +export function countChildren(children: ReactNode): number { + return Children.count(getFragmentChildren(children)); +} diff --git a/src/lib/redis.ts b/src/lib/redis.ts new file mode 100644 index 0000000..edde3d6 --- /dev/null +++ b/src/lib/redis.ts @@ -0,0 +1,18 @@ +import { UmamiRedisClient } from '@umami/redis-client'; + +const REDIS = 'redis'; +const enabled = !!process.env.REDIS_URL; + +function getClient() { + const redis = new UmamiRedisClient({ url: process.env.REDIS_URL }); + + if (process.env.NODE_ENV !== 'production') { + globalThis[REDIS] = redis; + } + + return redis; +} + +const client = globalThis[REDIS] || getClient(); + +export default { client, enabled }; diff --git a/src/lib/request.ts b/src/lib/request.ts new file mode 100644 index 0000000..42c4490 --- /dev/null +++ b/src/lib/request.ts @@ -0,0 +1,145 @@ +import { z } from 'zod'; +import { checkAuth } from '@/lib/auth'; +import { DEFAULT_PAGE_SIZE, FILTER_COLUMNS } from '@/lib/constants'; +import { getAllowedUnits, getMinimumUnit, maxDate, parseDateRange } from '@/lib/date'; +import { fetchWebsite } from '@/lib/load'; +import { filtersArrayToObject } from '@/lib/params'; +import { badRequest, unauthorized } from '@/lib/response'; +import type { QueryFilters } from '@/lib/types'; +import { getWebsiteSegment } from '@/queries/prisma'; + +export async function parseRequest( + request: Request, + schema?: any, + options?: { skipAuth: boolean }, +): Promise<any> { + const url = new URL(request.url); + let query = Object.fromEntries(url.searchParams); + let body = await getJsonBody(request); + let error: () => undefined | undefined; + let auth = null; + + if (schema) { + const isGet = request.method === 'GET'; + const result = schema.safeParse(isGet ? query : body); + + if (!result.success) { + error = () => badRequest(z.treeifyError(result.error)); + } else if (isGet) { + query = result.data; + } else { + body = result.data; + } + } + + if (!options?.skipAuth && !error) { + auth = await checkAuth(request); + + if (!auth) { + error = () => unauthorized(); + } + } + + return { url, query, body, auth, error }; +} + +export async function getJsonBody(request: Request) { + try { + return await request.clone().json(); + } catch { + return undefined; + } +} + +export function getRequestDateRange(query: Record<string, string>) { + const { startAt, endAt, unit, timezone } = query; + + const startDate = new Date(+startAt); + const endDate = new Date(+endAt); + + return { + startDate, + endDate, + timezone, + unit: getAllowedUnits(startDate, endDate).includes(unit) + ? unit + : getMinimumUnit(startDate, endDate), + }; +} + +export function getRequestFilters(query: Record<string, any>) { + const result: Record<string, any> = {}; + + for (const key of Object.keys(FILTER_COLUMNS)) { + const value = query[key]; + if (value !== undefined) { + result[key] = value; + } + } + + return result; +} + +export async function setWebsiteDate(websiteId: string, data: Record<string, any>) { + const website = await fetchWebsite(websiteId); + + if (website?.resetAt) { + data.startDate = maxDate(data.startDate, new Date(website?.resetAt)); + } + + return data; +} + +export async function getQueryFilters( + params: Record<string, any>, + websiteId?: string, +): Promise<QueryFilters> { + const dateRange = getRequestDateRange(params); + const filters = getRequestFilters(params); + + if (websiteId) { + await setWebsiteDate(websiteId, dateRange); + + if (params.segment) { + const segmentParams = (await getWebsiteSegment(websiteId, params.segment)) + ?.parameters as Record<string, any>; + + Object.assign(filters, filtersArrayToObject(segmentParams.filters)); + } + + if (params.cohort) { + const cohortParams = (await getWebsiteSegment(websiteId, params.cohort)) + ?.parameters as Record<string, any>; + + const { startDate, endDate } = parseDateRange(cohortParams.dateRange); + + const cohortFilters = cohortParams.filters.map(({ name, ...props }) => ({ + ...props, + name: `cohort_${name}`, + })); + + cohortFilters.push({ + name: `cohort_${cohortParams.action.type}`, + operator: 'eq', + value: cohortParams.action.value, + }); + + Object.assign(filters, { + ...filtersArrayToObject(cohortFilters), + cohort_startDate: startDate, + cohort_endDate: endDate, + }); + } + } + + return { + ...dateRange, + ...filters, + page: params?.page, + pageSize: params?.pageSize ? params?.pageSize || DEFAULT_PAGE_SIZE : undefined, + orderBy: params?.orderBy, + sortDescending: params?.sortDescending, + search: params?.search, + compare: params?.compare, + }; +} diff --git a/src/lib/response.ts b/src/lib/response.ts new file mode 100644 index 0000000..f1ad5c7 --- /dev/null +++ b/src/lib/response.ts @@ -0,0 +1,58 @@ +export function ok() { + return Response.json({ ok: true }); +} + +export function json(data: Record<string, any> = {}) { + return Response.json(data); +} + +export function badRequest(error?: Record<string, any>) { + return Response.json( + { + error: { message: 'Bad request', code: 'bad-request', status: 400, ...error }, + }, + { status: 400 }, + ); +} + +export function unauthorized(error?: Record<string, any>) { + return Response.json( + { + error: { + message: 'Unauthorized', + code: 'unauthorized', + status: 401, + ...error, + }, + }, + { status: 401 }, + ); +} + +export function forbidden(error?: Record<string, any>) { + return Response.json( + { error: { message: 'Forbidden', code: 'forbidden', status: 403, ...error } }, + { status: 403 }, + ); +} + +export function notFound(error?: Record<string, any>) { + return Response.json( + { error: { message: 'Not found', code: 'not-found', status: 404, ...error } }, + { status: 404 }, + ); +} + +export function serverError(error?: Record<string, any>) { + return Response.json( + { + error: { + message: 'Server error', + code: 'server-error', + status: 500, + ...error, + }, + }, + { status: 500 }, + ); +} diff --git a/src/lib/schema.ts b/src/lib/schema.ts new file mode 100644 index 0000000..38f7339 --- /dev/null +++ b/src/lib/schema.ts @@ -0,0 +1,232 @@ +import { z } from 'zod'; +import { isValidTimezone, normalizeTimezone } from '@/lib/date'; +import { UNIT_TYPES } from './constants'; + +export const timezoneParam = z + .string() + .refine((value: string) => isValidTimezone(value), { + message: 'Invalid timezone', + }) + .transform((value: string) => normalizeTimezone(value)); + +export const unitParam = z.string().refine(value => UNIT_TYPES.includes(value), { + message: 'Invalid unit', +}); + +export const dateRangeParams = { + startAt: z.coerce.number().optional(), + endAt: z.coerce.number().optional(), + startDate: z.coerce.date().optional(), + endDate: z.coerce.date().optional(), + timezone: timezoneParam.optional(), + unit: unitParam.optional(), + compare: z.string().optional(), +}; + +export const filterParams = { + path: z.string().optional(), + referrer: z.string().optional(), + title: z.string().optional(), + query: z.string().optional(), + os: z.string().optional(), + browser: z.string().optional(), + device: z.string().optional(), + country: z.string().optional(), + region: z.string().optional(), + city: z.string().optional(), + tag: z.string().optional(), + hostname: z.string().optional(), + language: z.string().optional(), + event: z.string().optional(), + segment: z.uuid().optional(), + cohort: z.uuid().optional(), + eventType: z.coerce.number().int().positive().optional(), +}; + +export const searchParams = { + search: z.string().optional(), +}; + +export const pagingParams = { + page: z.coerce.number().int().positive().optional(), + pageSize: z.coerce.number().int().positive().optional(), +}; + +export const sortingParams = { + orderBy: z.string().optional(), +}; + +export const userRoleParam = z.enum(['admin', 'user', 'view-only']); + +export const teamRoleParam = z.enum(['team-member', 'team-view-only', 'team-manager']); + +export const anyObjectParam = z.record(z.string(), z.any()); + +export const urlOrPathParam = z.string().refine( + value => { + try { + new URL(value, 'https://localhost'); + return true; + } catch { + return false; + } + }, + { + message: 'Invalid URL.', + }, +); + +export const fieldsParam = z.enum([ + 'path', + 'referrer', + 'title', + 'query', + 'os', + 'browser', + 'device', + 'country', + 'region', + 'city', + 'tag', + 'hostname', + 'language', + 'event', +]); + +export const reportTypeParam = z.enum([ + 'attribution', + 'breakdown', + 'funnel', + 'goal', + 'journey', + 'retention', + 'revenue', + 'utm', +]); + +export const goalReportSchema = z.object({ + type: z.literal('goal'), + parameters: z + .object({ + startDate: z.coerce.date(), + endDate: z.coerce.date(), + type: z.string(), + value: z.string(), + operator: z.enum(['count', 'sum', 'average']).optional(), + property: z.string().optional(), + }) + .refine(data => { + if (data.type === 'event' && data.property) { + return data.operator && data.property; + } + return true; + }), +}); + +export const funnelReportSchema = z.object({ + type: z.literal('funnel'), + parameters: z.object({ + startDate: z.coerce.date(), + endDate: z.coerce.date(), + window: z.coerce.number().positive(), + steps: z + .array( + z.object({ + type: z.enum(['path', 'event']), + value: z.string(), + }), + ) + .min(2) + .max(8), + }), +}); + +export const journeyReportSchema = z.object({ + type: z.literal('journey'), + parameters: z.object({ + startDate: z.coerce.date(), + endDate: z.coerce.date(), + steps: z.coerce.number().min(2).max(7), + startStep: z.string().optional(), + endStep: z.string().optional(), + }), +}); + +export const retentionReportSchema = z.object({ + type: z.literal('retention'), + parameters: z.object({ + startDate: z.coerce.date(), + endDate: z.coerce.date(), + timezone: z.string().optional(), + }), +}); + +export const utmReportSchema = z.object({ + type: z.literal('utm'), + parameters: z.object({ + startDate: z.coerce.date(), + endDate: z.coerce.date(), + }), +}); + +export const revenueReportSchema = z.object({ + type: z.literal('revenue'), + parameters: z.object({ + startDate: z.coerce.date(), + endDate: z.coerce.date(), + timezone: z.string().optional(), + currency: z.string(), + }), +}); + +export const attributionReportSchema = z.object({ + type: z.literal('attribution'), + parameters: z.object({ + startDate: z.coerce.date(), + endDate: z.coerce.date(), + model: z.enum(['first-click', 'last-click']), + type: z.enum(['path', 'event']), + step: z.string(), + currency: z.string().optional(), + }), +}); + +export const breakdownReportSchema = z.object({ + type: z.literal('breakdown'), + parameters: z.object({ + startDate: z.coerce.date(), + endDate: z.coerce.date(), + fields: z.array(fieldsParam), + }), +}); + +export const reportBaseSchema = z.object({ + websiteId: z.uuid(), + type: reportTypeParam, + name: z.string().max(200), + description: z.string().max(500).optional(), + parameters: anyObjectParam, +}); + +export const reportTypeSchema = z.discriminatedUnion('type', [ + goalReportSchema, + funnelReportSchema, + journeyReportSchema, + retentionReportSchema, + utmReportSchema, + revenueReportSchema, + attributionReportSchema, + breakdownReportSchema, +]); + +export const reportSchema = reportBaseSchema; + +export const reportResultSchema = z.intersection( + z.object({ + websiteId: z.uuid(), + filters: z.object({ ...filterParams }), + }), + reportTypeSchema, +); + +export const segmentTypeParam = z.enum(['segment', 'cohort']); diff --git a/src/lib/sql.ts b/src/lib/sql.ts new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/lib/sql.ts diff --git a/src/lib/storage.ts b/src/lib/storage.ts new file mode 100644 index 0000000..19681a2 --- /dev/null +++ b/src/lib/storage.ts @@ -0,0 +1,25 @@ +export function setItem(key: string, data: any, session?: boolean) { + if (typeof window !== 'undefined' && data) { + return (session ? sessionStorage : localStorage).setItem(key, JSON.stringify(data)); + } +} + +export function getItem(key: string, session?: boolean): any { + if (typeof window !== 'undefined') { + const value = (session ? sessionStorage : localStorage).getItem(key); + + if (value !== 'undefined' && value !== null) { + try { + return JSON.parse(value); + } catch { + return null; + } + } + } +} + +export function removeItem(key: string, session?: boolean) { + if (typeof window !== 'undefined') { + return (session ? sessionStorage : localStorage).removeItem(key); + } +} diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..9c06197 --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,143 @@ +import type { UseQueryOptions } from '@tanstack/react-query'; +import type { DATA_TYPE, OPERATORS, ROLES } from './constants'; +import type { TIME_UNIT } from './date'; + +export type ObjectValues<T> = T[keyof T]; + +export type ReactQueryOptions<T = any> = Omit<UseQueryOptions<T, Error, T>, 'queryKey' | 'queryFn'>; + +export type TimeUnit = ObjectValues<typeof TIME_UNIT>; +export type Role = ObjectValues<typeof ROLES>; +export type DynamicDataType = ObjectValues<typeof DATA_TYPE>; +export type Operator = (typeof OPERATORS)[keyof typeof OPERATORS]; + +export interface Auth { + user?: { + id: string; + username: string; + role: string; + isAdmin: boolean; + }; + shareToken?: { + websiteId: string; + }; +} + +export interface Filter { + name: string; + operator: Operator; + value: string; + type?: string; + column?: string; + prefix?: string; +} + +export interface DateRange { + startDate: Date; + endDate: Date; + value?: string; + unit?: TimeUnit; + num?: number; + offset?: number; +} + +export interface DynamicData { + [key: string]: number | string | number[] | string[]; +} + +export interface QueryOptions { + joinSession?: boolean; + columns?: Record<string, string>; + limit?: number; + prefix?: string; + isCohort?: boolean; +} + +export interface QueryFilters + extends DateParams, + FilterParams, + SortParams, + PageParams, + SegmentParams { + cohortFilters?: QueryFilters; +} + +export interface DateParams { + startDate?: Date; + endDate?: Date; + unit?: string; + timezone?: string; + compareDate?: Date; +} + +export interface FilterParams { + path?: string; + referrer?: string; + title?: string; + query?: string; + host?: string; + os?: string; + browser?: string; + device?: string; + country?: string; + region?: string; + city?: string; + language?: string; + event?: string; + search?: string; + tag?: string; + eventType?: number; + segment?: string; + cohort?: string; + compare?: string; +} + +export interface SortParams { + orderBy?: string; + sortDescending?: boolean; +} + +export interface PageParams { + page?: number; + pageSize?: number; +} + +export interface SegmentParams { + segment?: string; + cohort?: string; +} + +export interface PageResult<T> { + data: T; + count: number; + page: number; + pageSize: number; + orderBy?: string; + sortDescending?: boolean; + search?: string; +} + +export interface RealtimeData { + countries: Record<string, number>; + events: any[]; + pageviews: any[]; + referrers: Record<string, number>; + timestamp: number; + series: { + views: any[]; + visitors: any[]; + }; + totals: { + views: number; + visitors: number; + events: number; + countries: number; + }; + urls: Record<string, number>; + visitors: any[]; +} + +export interface ApiError extends Error { + code?: string; + message: string; +} diff --git a/src/lib/url.ts b/src/lib/url.ts new file mode 100644 index 0000000..f6772fe --- /dev/null +++ b/src/lib/url.ts @@ -0,0 +1,49 @@ +export function getQueryString(params: object = {}): string { + const searchParams = new URLSearchParams(); + + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined) { + searchParams.append(key, value); + } + }); + + return searchParams.toString(); +} + +export function buildPath(path: string, params: object = {}): string { + const queryString = getQueryString(params); + return queryString ? `${path}?${queryString}` : path; +} + +export function safeDecodeURI(s: string | undefined | null): string | undefined | null { + if (s === undefined || s === null) { + return s; + } + + try { + return decodeURI(s); + } catch { + return s; + } +} + +export function safeDecodeURIComponent(s: string | undefined | null): string | undefined | null { + if (s === undefined || s === null) { + return s; + } + + try { + return decodeURIComponent(s); + } catch { + return s; + } +} + +export function isValidUrl(url: string) { + try { + new URL(url); + return true; + } catch { + return false; + } +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..2b0d9ff --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,46 @@ +export function hook( + _this: { [x: string]: any }, + method: string | number, + callback: (arg0: any) => void, +) { + const orig = _this[method]; + + return (...args: any) => { + callback.apply(_this, args); + + return orig.apply(_this, args); + }; +} + +export function sleep(ms: number | undefined) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +export function shuffleArray(a) { + const arr = a.slice(); + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + const temp = arr[i]; + arr[i] = arr[j]; + arr[j] = temp; + } + return arr; +} + +export function chunkArray(arr: any[], size: number) { + const chunks: any[] = []; + + let index = 0; + while (index < arr.length) { + chunks.push(arr.slice(index, size + index)); + index += size; + } + + return chunks; +} + +export function ensureArray(arr?: any) { + if (arr === undefined || arr === null) return []; + if (Array.isArray(arr)) return arr; + return [arr]; +} |