mirror of
https://github.com/kremalicious/umami.git
synced 2024-11-22 01:46:58 +01:00
add back lib folder
This commit is contained in:
parent
f93584092a
commit
78cdd4cac4
226
src/lib/auth.ts
Normal file
226
src/lib/auth.ts
Normal file
@ -0,0 +1,226 @@
|
||||
import { Report } from '@prisma/client';
|
||||
import debug from 'debug';
|
||||
import redis from '@umami/redis-client';
|
||||
import { PERMISSIONS, ROLE_PERMISSIONS, SHARE_TOKEN_HEADER } from 'lib/constants';
|
||||
import { secret } from 'lib/crypto';
|
||||
import { createSecureToken, ensureArray, getRandomChars, parseToken } from 'next-basics';
|
||||
import { findTeamWebsiteByUserId, getTeamUser, getTeamWebsite } from 'queries';
|
||||
import { loadWebsite } from './load';
|
||||
import { Auth } from './types';
|
||||
import { NextApiRequest } from 'next';
|
||||
|
||||
const log = debug('umami:auth');
|
||||
const cloudMode = process.env.CLOUD_MODE;
|
||||
|
||||
export async function saveAuth(data: any, expire = 0) {
|
||||
const authKey = `auth:${getRandomChars(32)}`;
|
||||
|
||||
await redis.client.set(authKey, data);
|
||||
|
||||
if (expire) {
|
||||
await redis.client.expire(authKey, expire);
|
||||
}
|
||||
|
||||
return createSecureToken({ authKey }, secret());
|
||||
}
|
||||
|
||||
export function getAuthToken(req: NextApiRequest) {
|
||||
try {
|
||||
return req.headers.authorization.split(' ')[1];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseShareToken(req: Request) {
|
||||
try {
|
||||
return parseToken(req.headers[SHARE_TOKEN_HEADER], secret());
|
||||
} catch (e) {
|
||||
log(e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function canViewWebsite({ user, shareToken }: Auth, websiteId: string) {
|
||||
if (user?.isAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (shareToken?.websiteId === websiteId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const website = await loadWebsite(websiteId);
|
||||
|
||||
if (user.id === website?.userId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !!(await findTeamWebsiteByUserId(websiteId, user.id));
|
||||
}
|
||||
|
||||
export async function canViewAllWebsites({ user }: Auth) {
|
||||
return user.isAdmin;
|
||||
}
|
||||
|
||||
export async function canCreateWebsite({ user, grant }: Auth) {
|
||||
if (cloudMode) {
|
||||
return !!grant?.find(a => a === PERMISSIONS.websiteCreate);
|
||||
}
|
||||
|
||||
if (user.isAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return hasPermission(user.role, PERMISSIONS.websiteCreate);
|
||||
}
|
||||
|
||||
export async function canUpdateWebsite({ user }: Auth, websiteId: string) {
|
||||
if (user.isAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const website = await loadWebsite(websiteId);
|
||||
|
||||
return user.id === website?.userId;
|
||||
}
|
||||
|
||||
export async function canDeleteWebsite({ user }: Auth, websiteId: string) {
|
||||
if (user.isAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const website = await loadWebsite(websiteId);
|
||||
|
||||
return user.id === website?.userId;
|
||||
}
|
||||
|
||||
export async function canViewReport(auth: Auth, report: Report) {
|
||||
if (auth.user.isAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (auth.user.id == report.userId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !!(await canViewWebsite(auth, report.websiteId));
|
||||
}
|
||||
|
||||
export async function canUpdateReport({ user }: Auth, report: Report) {
|
||||
if (user.isAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return user.id == report.userId;
|
||||
}
|
||||
|
||||
export async function canDeleteReport(auth: Auth, report: Report) {
|
||||
return canUpdateReport(auth, report);
|
||||
}
|
||||
|
||||
export async function canCreateTeam({ user, grant }: Auth) {
|
||||
if (cloudMode) {
|
||||
return !!grant?.find(a => a === PERMISSIONS.teamCreate);
|
||||
}
|
||||
|
||||
if (user.isAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !!user;
|
||||
}
|
||||
|
||||
export async function canViewTeam({ user }: Auth, teamId: string) {
|
||||
if (user.isAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return getTeamUser(teamId, user.id);
|
||||
}
|
||||
|
||||
export async function canUpdateTeam({ user }: Auth, teamId: string) {
|
||||
if (user.isAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const teamUser = await getTeamUser(teamId, user.id);
|
||||
|
||||
return teamUser && hasPermission(teamUser.role, PERMISSIONS.teamUpdate);
|
||||
}
|
||||
|
||||
export async function canDeleteTeam({ user }: Auth, teamId: string) {
|
||||
if (user.isAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const teamUser = await getTeamUser(teamId, user.id);
|
||||
|
||||
return teamUser && hasPermission(teamUser.role, PERMISSIONS.teamDelete);
|
||||
}
|
||||
|
||||
export async function canDeleteTeamUser({ user }: Auth, teamId: string, removeUserId: string) {
|
||||
if (user.isAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (removeUserId === user.id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const teamUser = await getTeamUser(teamId, user.id);
|
||||
|
||||
return teamUser && hasPermission(teamUser.role, PERMISSIONS.teamUpdate);
|
||||
}
|
||||
|
||||
export async function canDeleteTeamWebsite({ user }: Auth, teamId: string, websiteId: string) {
|
||||
if (user.isAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const teamWebsite = await getTeamWebsite(teamId, websiteId);
|
||||
|
||||
if (teamWebsite?.website?.userId === user.id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (teamWebsite) {
|
||||
const teamUser = await getTeamUser(teamWebsite.teamId, user.id);
|
||||
|
||||
return hasPermission(teamUser.role, PERMISSIONS.teamUpdate);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function canCreateUser({ user }: Auth) {
|
||||
return user.isAdmin;
|
||||
}
|
||||
|
||||
export async function canViewUser({ user }: Auth, viewedUserId: string) {
|
||||
if (user.isAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return user.id === viewedUserId;
|
||||
}
|
||||
|
||||
export async function canViewUsers({ user }: Auth) {
|
||||
return user.isAdmin;
|
||||
}
|
||||
|
||||
export async function canUpdateUser({ user }: Auth, viewedUserId: string) {
|
||||
if (user.isAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return user.id === viewedUserId;
|
||||
}
|
||||
|
||||
export async function canDeleteUser({ user }: Auth) {
|
||||
return user.isAdmin;
|
||||
}
|
||||
|
||||
export async function hasPermission(role: string, permission: string | string[]) {
|
||||
return ensureArray(permission).some(e => ROLE_PERMISSIONS[role]?.includes(e));
|
||||
}
|
86
src/lib/cache.ts
Normal file
86
src/lib/cache.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import { User, Website } from '@prisma/client';
|
||||
import redis from '@umami/redis-client';
|
||||
import { getSession, getUserById, getWebsiteById } from '../queries';
|
||||
|
||||
async function fetchWebsite(id): Promise<Website> {
|
||||
return redis.client.getCache(`website:${id}`, () => getWebsiteById(id), 86400);
|
||||
}
|
||||
|
||||
async function storeWebsite(data) {
|
||||
const { id } = data;
|
||||
const key = `website:${id}`;
|
||||
|
||||
const obj = await redis.client.setCache(key, data);
|
||||
await redis.client.expire(key, 86400);
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
async function deleteWebsite(id) {
|
||||
return redis.client.deleteCache(`website:${id}`);
|
||||
}
|
||||
|
||||
async function fetchUser(id): Promise<User> {
|
||||
return redis.client.getCache(
|
||||
`user:${id}`,
|
||||
() => getUserById(id, { includePassword: true }),
|
||||
86400,
|
||||
);
|
||||
}
|
||||
|
||||
async function storeUser(data) {
|
||||
const { id } = data;
|
||||
const key = `user:${id}`;
|
||||
|
||||
const obj = await redis.client.setCache(key, data);
|
||||
await redis.client.expire(key, 86400);
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
async function deleteUser(id) {
|
||||
return redis.client.deleteCache(`user:${id}`);
|
||||
}
|
||||
|
||||
async function fetchSession(id) {
|
||||
return redis.client.getCache(`session:${id}`, () => getSession(id), 86400);
|
||||
}
|
||||
|
||||
async function storeSession(data) {
|
||||
const { id } = data;
|
||||
const key = `session:${id}`;
|
||||
|
||||
const obj = await redis.client.setCache(key, data);
|
||||
await redis.client.expire(key, 86400);
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
async function deleteSession(id) {
|
||||
return redis.client.deleteCache(`session:${id}`);
|
||||
}
|
||||
|
||||
async function fetchUserBlock(userId: string) {
|
||||
const key = `user:block:${userId}`;
|
||||
return redis.client.get(key);
|
||||
}
|
||||
|
||||
async function incrementUserBlock(userId: string) {
|
||||
const key = `user:block:${userId}`;
|
||||
return redis.client.incr(key);
|
||||
}
|
||||
|
||||
export default {
|
||||
fetchWebsite,
|
||||
storeWebsite,
|
||||
deleteWebsite,
|
||||
fetchUser,
|
||||
storeUser,
|
||||
deleteUser,
|
||||
fetchSession,
|
||||
storeSession,
|
||||
deleteSession,
|
||||
fetchUserBlock,
|
||||
incrementUserBlock,
|
||||
enabled: !!redis.enabled,
|
||||
};
|
62
src/lib/charts.tsx
Normal file
62
src/lib/charts.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import { StatusLight } from 'react-basics';
|
||||
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, 'MMM d', locale);
|
||||
case 'month':
|
||||
return formatDate(d, 'MMM', locale);
|
||||
case 'year':
|
||||
return formatDate(d, 'YYY', locale);
|
||||
default:
|
||||
return label;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function renderStatusTooltipPopup(unit: string, locale: string) {
|
||||
return (setTooltipPopup: (data: any) => void, model: any) => {
|
||||
const { opacity, labelColors, dataPoints } = model.tooltip;
|
||||
|
||||
if (!dataPoints?.length || !opacity) {
|
||||
setTooltipPopup(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const formats = {
|
||||
millisecond: 'T',
|
||||
second: 'pp',
|
||||
minute: 'p',
|
||||
hour: 'h:mm aaa - PP',
|
||||
day: 'PPPP',
|
||||
week: 'PPPP',
|
||||
month: 'LLLL yyyy',
|
||||
quarter: 'qqq',
|
||||
year: 'yyyy',
|
||||
};
|
||||
|
||||
setTooltipPopup(
|
||||
<>
|
||||
<div>{formatDate(new Date(dataPoints[0].raw.x), formats[unit], locale)}</div>
|
||||
<div>
|
||||
<StatusLight color={labelColors?.[0]?.backgroundColor}>
|
||||
{formatLongNumber(dataPoints[0].raw.y)} {dataPoints[0].dataset.label}
|
||||
</StatusLight>
|
||||
</div>
|
||||
</>,
|
||||
);
|
||||
};
|
||||
}
|
171
src/lib/clickhouse.ts
Normal file
171
src/lib/clickhouse.ts
Normal file
@ -0,0 +1,171 @@
|
||||
import { ClickHouseClient, createClient } from '@clickhouse/client';
|
||||
import dateFormat from 'dateformat';
|
||||
import debug from 'debug';
|
||||
import { CLICKHOUSE } from 'lib/db';
|
||||
import { QueryFilters, QueryOptions } from './types';
|
||||
import { FILTER_COLUMNS, OPERATORS } from './constants';
|
||||
import { loadWebsite } from './load';
|
||||
import { maxDate } from './date';
|
||||
|
||||
export const CLICKHOUSE_DATE_FORMATS = {
|
||||
minute: '%Y-%m-%d %H:%M: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({
|
||||
host: `${protocol}//${hostname}:${port}`,
|
||||
database: pathname.replace('/', ''),
|
||||
username: username,
|
||||
password,
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
global[CLICKHOUSE] = client;
|
||||
}
|
||||
|
||||
log('Clickhouse initialized');
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
function getDateStringQuery(data, unit) {
|
||||
return `formatDateTime(${data}, '${CLICKHOUSE_DATE_FORMATS[unit]}')`;
|
||||
}
|
||||
|
||||
function getDateQuery(field, unit, timezone?) {
|
||||
if (timezone) {
|
||||
return `date_trunc('${unit}', ${field}, '${timezone}')`;
|
||||
}
|
||||
return `date_trunc('${unit}', ${field})`;
|
||||
}
|
||||
|
||||
function getDateFormat(date) {
|
||||
return `'${dateFormat(date, 'UTC:yyyy-mm-dd HH:MM:ss')}'`;
|
||||
}
|
||||
|
||||
function mapFilter(column, operator, name, type = 'String') {
|
||||
switch (operator) {
|
||||
case OPERATORS.equals:
|
||||
return `${column} = {${name}:${type}}`;
|
||||
case OPERATORS.notEquals:
|
||||
return `${column} != {${name}:${type}}`;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function getFilterQuery(filters: QueryFilters = {}, options: QueryOptions = {}) {
|
||||
const query = Object.keys(filters).reduce((arr, name) => {
|
||||
const value = filters[name];
|
||||
const operator = value?.filter ?? OPERATORS.equals;
|
||||
const column = FILTER_COLUMNS[name] ?? options?.columns?.[name];
|
||||
|
||||
if (value !== undefined && column) {
|
||||
arr.push(`and ${mapFilter(column, operator, name)}`);
|
||||
|
||||
if (name === 'referrer') {
|
||||
arr.push('and referrer_domain != {websiteDomain:String}');
|
||||
}
|
||||
}
|
||||
|
||||
return arr;
|
||||
}, []);
|
||||
|
||||
return query.join('\n');
|
||||
}
|
||||
|
||||
function normalizeFilters(filters = {}) {
|
||||
return Object.keys(filters).reduce((obj, key) => {
|
||||
const value = filters[key];
|
||||
|
||||
obj[key] = value?.value ?? value;
|
||||
|
||||
return obj;
|
||||
}, {});
|
||||
}
|
||||
|
||||
async function parseFilters(websiteId: string, filters: QueryFilters = {}, options?: QueryOptions) {
|
||||
const website = await loadWebsite(websiteId);
|
||||
|
||||
return {
|
||||
filterQuery: getFilterQuery(filters, options),
|
||||
params: {
|
||||
...normalizeFilters(filters),
|
||||
websiteId,
|
||||
startDate: maxDate(filters.startDate, new Date(website.resetAt)),
|
||||
websiteDomain: website.domain,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function rawQuery(query: string, params: Record<string, unknown> = {}): Promise<unknown> {
|
||||
if (process.env.LOG_QUERY) {
|
||||
log('QUERY:\n', query);
|
||||
log('PARAMETERS:\n', params);
|
||||
}
|
||||
|
||||
await connect();
|
||||
|
||||
const resultSet = await clickhouse.query({
|
||||
query: query,
|
||||
query_params: params,
|
||||
format: 'JSONEachRow',
|
||||
});
|
||||
|
||||
const data = await resultSet.json();
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async function findUnique(data) {
|
||||
if (data.length > 1) {
|
||||
throw `${data.length} records found when expecting 1.`;
|
||||
}
|
||||
|
||||
return findFirst(data);
|
||||
}
|
||||
|
||||
async function findFirst(data) {
|
||||
return data[0] ?? null;
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
if (enabled && !clickhouse) {
|
||||
clickhouse = process.env.CLICKHOUSE_URL && (global[CLICKHOUSE] || getClient());
|
||||
}
|
||||
|
||||
return clickhouse;
|
||||
}
|
||||
|
||||
export default {
|
||||
enabled,
|
||||
client: clickhouse,
|
||||
log,
|
||||
connect,
|
||||
getDateStringQuery,
|
||||
getDateQuery,
|
||||
getDateFormat,
|
||||
getFilterQuery,
|
||||
parseFilters,
|
||||
findUnique,
|
||||
findFirst,
|
||||
rawQuery,
|
||||
};
|
14
src/lib/client.ts
Normal file
14
src/lib/client.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { getItem, setItem, removeItem } from 'next-basics';
|
||||
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);
|
||||
}
|
521
src/lib/constants.ts
Normal file
521
src/lib/constants.ts
Normal file
@ -0,0 +1,521 @@
|
||||
/* eslint-disable no-unused-vars */
|
||||
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 VERSION_CHECK = 'umami.version-check';
|
||||
export const SHARE_TOKEN_HEADER = 'x-umami-share-token';
|
||||
export const HOMEPAGE_URL = 'https://umami.is';
|
||||
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 DEFAULT_LOCALE = process.env.defaultLocale || 'en-US';
|
||||
export const DEFAULT_THEME = 'light';
|
||||
export const DEFAULT_ANIMATION_DURATION = 300;
|
||||
export const DEFAULT_DATE_RANGE = '24hour';
|
||||
export const DEFAULT_WEBSITE_LIMIT = 10;
|
||||
export const DEFAULT_RESET_DATE = '2000-01-01';
|
||||
export const DEFAULT_PAGE_SIZE = 10;
|
||||
|
||||
export const REALTIME_RANGE = 30;
|
||||
export const REALTIME_INTERVAL = 5000;
|
||||
|
||||
export const FILTER_COMBINED = 'filter-combined';
|
||||
export const FILTER_RAW = 'filter-raw';
|
||||
export const FILTER_DAY = 'filter-day';
|
||||
export const FILTER_RANGE = 'filter-range';
|
||||
export const FILTER_REFERRERS = 'filter-referrers';
|
||||
export const FILTER_PAGES = 'filter-pages';
|
||||
export const UNIT_TYPES = ['year', 'month', 'hour', 'day'];
|
||||
export const EVENT_COLUMNS = ['url', 'referrer', 'title', 'query', 'event'];
|
||||
|
||||
export const SESSION_COLUMNS = [
|
||||
'browser',
|
||||
'os',
|
||||
'device',
|
||||
'screen',
|
||||
'language',
|
||||
'country',
|
||||
'region',
|
||||
'city',
|
||||
];
|
||||
|
||||
export const FILTER_COLUMNS = {
|
||||
url: 'url_path',
|
||||
referrer: 'referrer_domain',
|
||||
title: 'page_title',
|
||||
query: 'url_query',
|
||||
os: 'os',
|
||||
browser: 'browser',
|
||||
device: 'device',
|
||||
country: 'country',
|
||||
region: 'subdivision1',
|
||||
city: 'city',
|
||||
language: 'language',
|
||||
event: 'event_name',
|
||||
};
|
||||
|
||||
export const COLLECTION_TYPE = {
|
||||
event: 'event',
|
||||
identify: 'identify',
|
||||
};
|
||||
|
||||
export const EVENT_TYPE = {
|
||||
pageView: 1,
|
||||
customEvent: 2,
|
||||
} 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',
|
||||
};
|
||||
|
||||
export const REPORT_TYPES = {
|
||||
funnel: 'funnel',
|
||||
insights: 'insights',
|
||||
retention: 'retention',
|
||||
} as const;
|
||||
|
||||
export const REPORT_PARAMETERS = {
|
||||
fields: 'fields',
|
||||
filters: 'filters',
|
||||
groups: 'groups',
|
||||
} as const;
|
||||
|
||||
export const KAFKA_TOPIC = {
|
||||
event: 'event',
|
||||
eventData: 'event_data',
|
||||
} as const;
|
||||
|
||||
export const ROLES = {
|
||||
admin: 'admin',
|
||||
user: 'user',
|
||||
viewOnly: 'view-only',
|
||||
teamOwner: 'team-owner',
|
||||
teamMember: 'team-member',
|
||||
} as const;
|
||||
|
||||
export const PERMISSIONS = {
|
||||
all: 'all',
|
||||
websiteCreate: 'website:create',
|
||||
websiteUpdate: 'website:update',
|
||||
websiteDelete: 'website:delete',
|
||||
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],
|
||||
[ROLES.teamMember]: [],
|
||||
} as const;
|
||||
|
||||
export const THEME_COLORS = {
|
||||
light: {
|
||||
primary: '#2680eb',
|
||||
gray50: '#ffffff',
|
||||
gray75: '#fafafa',
|
||||
gray100: '#f5f5f5',
|
||||
gray200: '#eaeaea',
|
||||
gray300: '#e1e1e1',
|
||||
gray400: '#cacaca',
|
||||
gray500: '#b3b3b3',
|
||||
gray600: '#8e8e8e',
|
||||
gray700: '#6e6e6e',
|
||||
gray800: '#4b4b4b',
|
||||
gray900: '#2c2c2c',
|
||||
},
|
||||
dark: {
|
||||
primary: '#2680eb',
|
||||
gray50: '#252525',
|
||||
gray75: '#2f2f2f',
|
||||
gray100: '#323232',
|
||||
gray200: '#3e3e3e',
|
||||
gray300: '#4a4a4a',
|
||||
gray400: '#5a5a5a',
|
||||
gray500: '#6e6e6e',
|
||||
gray600: '#909090',
|
||||
gray700: '#b9b9b9',
|
||||
gray800: '#e3e3e3',
|
||||
gray900: '#ffffff',
|
||||
},
|
||||
};
|
||||
|
||||
export const EVENT_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,16}$/;
|
||||
export const UUID_REGEX =
|
||||
/^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/;
|
||||
export const HOSTNAME_REGEX =
|
||||
/^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/;
|
||||
|
||||
export const DESKTOP_SCREEN_WIDTH = 1920;
|
||||
export const LAPTOP_SCREEN_WIDTH = 1024;
|
||||
export const MOBILE_SCREEN_WIDTH = 479;
|
||||
|
||||
export const URL_LENGTH = 500;
|
||||
export const EVENT_NAME_LENGTH = 50;
|
||||
|
||||
export const DESKTOP_OS = [
|
||||
'BeOS',
|
||||
'Chrome OS',
|
||||
'Linux',
|
||||
'Mac OS',
|
||||
'Open BSD',
|
||||
'OS/2',
|
||||
'QNX',
|
||||
'Sun OS',
|
||||
'Windows 10',
|
||||
'Windows 2000',
|
||||
'Windows 3.11',
|
||||
'Windows 7',
|
||||
'Windows 8',
|
||||
'Windows 8.1',
|
||||
'Windows 95',
|
||||
'Windows 98',
|
||||
'Windows ME',
|
||||
'Windows Server 2003',
|
||||
'Windows Vista',
|
||||
'Windows XP',
|
||||
];
|
||||
|
||||
export const MOBILE_OS = ['Amazon OS', 'Android OS', 'BlackBerry OS', 'iOS', 'Windows Mobile'];
|
||||
|
||||
export const BROWSERS = {
|
||||
android: 'Android',
|
||||
aol: 'AOL',
|
||||
beaker: 'Beaker',
|
||||
bb10: 'BlackBerry 10',
|
||||
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',
|
||||
silk: 'Silk',
|
||||
searchbot: 'Searchbot',
|
||||
yandexbrowser: 'Yandex',
|
||||
};
|
||||
|
||||
export const MAP_FILE = '/datamaps.world.json';
|
||||
|
||||
export const ISO_COUNTRIES = {
|
||||
AFG: 'AF',
|
||||
ALA: 'AX',
|
||||
ALB: 'AL',
|
||||
DZA: 'DZ',
|
||||
ASM: 'AS',
|
||||
AND: 'AD',
|
||||
AGO: 'AO',
|
||||
AIA: 'AI',
|
||||
ATA: 'AQ',
|
||||
ATG: 'AG',
|
||||
ARG: 'AR',
|
||||
ARM: 'AM',
|
||||
ABW: 'AW',
|
||||
AUS: 'AU',
|
||||
AUT: 'AT',
|
||||
AZE: 'AZ',
|
||||
BHS: 'BS',
|
||||
BHR: 'BH',
|
||||
BGD: 'BD',
|
||||
BRB: 'BB',
|
||||
BLR: 'BY',
|
||||
BEL: 'BE',
|
||||
BLZ: 'BZ',
|
||||
BEN: 'BJ',
|
||||
BMU: 'BM',
|
||||
BTN: 'BT',
|
||||
BOL: 'BO',
|
||||
BIH: 'BA',
|
||||
BWA: 'BW',
|
||||
BVT: 'BV',
|
||||
BRA: 'BR',
|
||||
VGB: 'VG',
|
||||
IOT: 'IO',
|
||||
BRN: 'BN',
|
||||
BGR: 'BG',
|
||||
BFA: 'BF',
|
||||
BDI: 'BI',
|
||||
KHM: 'KH',
|
||||
CMR: 'CM',
|
||||
CAN: 'CA',
|
||||
CPV: 'CV',
|
||||
CYM: 'KY',
|
||||
CAF: 'CF',
|
||||
TCD: 'TD',
|
||||
CHL: 'CL',
|
||||
CHN: 'CN',
|
||||
HKG: 'HK',
|
||||
MAC: 'MO',
|
||||
CXR: 'CX',
|
||||
CCK: 'CC',
|
||||
COL: 'CO',
|
||||
COM: 'KM',
|
||||
COG: 'CG',
|
||||
COD: 'CD',
|
||||
COK: 'CK',
|
||||
CRI: 'CR',
|
||||
CIV: 'CI',
|
||||
HRV: 'HR',
|
||||
CUB: 'CU',
|
||||
CYP: 'CY',
|
||||
CZE: 'CZ',
|
||||
DNK: 'DK',
|
||||
DJI: 'DJ',
|
||||
DMA: 'DM',
|
||||
DOM: 'DO',
|
||||
ECU: 'EC',
|
||||
EGY: 'EG',
|
||||
SLV: 'SV',
|
||||
GNQ: 'GQ',
|
||||
ERI: 'ER',
|
||||
EST: 'EE',
|
||||
ETH: 'ET',
|
||||
FLK: 'FK',
|
||||
FRO: 'FO',
|
||||
FJI: 'FJ',
|
||||
FIN: 'FI',
|
||||
FRA: 'FR',
|
||||
GUF: 'GF',
|
||||
PYF: 'PF',
|
||||
ATF: 'TF',
|
||||
GAB: 'GA',
|
||||
GMB: 'GM',
|
||||
GEO: 'GE',
|
||||
DEU: 'DE',
|
||||
GHA: 'GH',
|
||||
GIB: 'GI',
|
||||
GRC: 'GR',
|
||||
GRL: 'GL',
|
||||
GRD: 'GD',
|
||||
GLP: 'GP',
|
||||
GUM: 'GU',
|
||||
GTM: 'GT',
|
||||
GGY: 'GG',
|
||||
GIN: 'GN',
|
||||
GNB: 'GW',
|
||||
GUY: 'GY',
|
||||
HTI: 'HT',
|
||||
HMD: 'HM',
|
||||
VAT: 'VA',
|
||||
HND: 'HN',
|
||||
HUN: 'HU',
|
||||
ISL: 'IS',
|
||||
IND: 'IN',
|
||||
IDN: 'ID',
|
||||
IRN: 'IR',
|
||||
IRQ: 'IQ',
|
||||
IRL: 'IE',
|
||||
IMN: 'IM',
|
||||
ISR: 'IL',
|
||||
ITA: 'IT',
|
||||
JAM: 'JM',
|
||||
JPN: 'JP',
|
||||
JEY: 'JE',
|
||||
JOR: 'JO',
|
||||
KAZ: 'KZ',
|
||||
KEN: 'KE',
|
||||
KIR: 'KI',
|
||||
PRK: 'KP',
|
||||
KOR: 'KR',
|
||||
KWT: 'KW',
|
||||
KGZ: 'KG',
|
||||
LAO: 'LA',
|
||||
LVA: 'LV',
|
||||
LBN: 'LB',
|
||||
LSO: 'LS',
|
||||
LBR: 'LR',
|
||||
LBY: 'LY',
|
||||
LIE: 'LI',
|
||||
LTU: 'LT',
|
||||
LUX: 'LU',
|
||||
MKD: 'MK',
|
||||
MDG: 'MG',
|
||||
MWI: 'MW',
|
||||
MYS: 'MY',
|
||||
MDV: 'MV',
|
||||
MLI: 'ML',
|
||||
MLT: 'MT',
|
||||
MHL: 'MH',
|
||||
MTQ: 'MQ',
|
||||
MRT: 'MR',
|
||||
MUS: 'MU',
|
||||
MYT: 'YT',
|
||||
MEX: 'MX',
|
||||
FSM: 'FM',
|
||||
MDA: 'MD',
|
||||
MCO: 'MC',
|
||||
MNG: 'MN',
|
||||
MNE: 'ME',
|
||||
MSR: 'MS',
|
||||
MAR: 'MA',
|
||||
MOZ: 'MZ',
|
||||
MMR: 'MM',
|
||||
NAM: 'NA',
|
||||
NRU: 'NR',
|
||||
NPL: 'NP',
|
||||
NLD: 'NL',
|
||||
ANT: 'AN',
|
||||
NCL: 'NC',
|
||||
NZL: 'NZ',
|
||||
NIC: 'NI',
|
||||
NER: 'NE',
|
||||
NGA: 'NG',
|
||||
NIU: 'NU',
|
||||
NFK: 'NF',
|
||||
MNP: 'MP',
|
||||
NOR: 'NO',
|
||||
OMN: 'OM',
|
||||
PAK: 'PK',
|
||||
PLW: 'PW',
|
||||
PSE: 'PS',
|
||||
PAN: 'PA',
|
||||
PNG: 'PG',
|
||||
PRY: 'PY',
|
||||
PER: 'PE',
|
||||
PHL: 'PH',
|
||||
PCN: 'PN',
|
||||
POL: 'PL',
|
||||
PRT: 'PT',
|
||||
PRI: 'PR',
|
||||
QAT: 'QA',
|
||||
REU: 'RE',
|
||||
ROU: 'RO',
|
||||
RUS: 'RU',
|
||||
RWA: 'RW',
|
||||
BLM: 'BL',
|
||||
SHN: 'SH',
|
||||
KNA: 'KN',
|
||||
LCA: 'LC',
|
||||
MAF: 'MF',
|
||||
SPM: 'PM',
|
||||
VCT: 'VC',
|
||||
WSM: 'WS',
|
||||
SMR: 'SM',
|
||||
STP: 'ST',
|
||||
SAU: 'SA',
|
||||
SEN: 'SN',
|
||||
SRB: 'RS',
|
||||
SYC: 'SC',
|
||||
SLE: 'SL',
|
||||
SGP: 'SG',
|
||||
SVK: 'SK',
|
||||
SVN: 'SI',
|
||||
SLB: 'SB',
|
||||
SOM: 'SO',
|
||||
ZAF: 'ZA',
|
||||
SGS: 'GS',
|
||||
SSD: 'SS',
|
||||
ESP: 'ES',
|
||||
LKA: 'LK',
|
||||
SDN: 'SD',
|
||||
SUR: 'SR',
|
||||
SJM: 'SJ',
|
||||
SWZ: 'SZ',
|
||||
SWE: 'SE',
|
||||
CHE: 'CH',
|
||||
SYR: 'SY',
|
||||
TWN: 'TW',
|
||||
TJK: 'TJ',
|
||||
TZA: 'TZ',
|
||||
THA: 'TH',
|
||||
TLS: 'TL',
|
||||
TGO: 'TG',
|
||||
TKL: 'TK',
|
||||
TON: 'TO',
|
||||
TTO: 'TT',
|
||||
TUN: 'TN',
|
||||
TUR: 'TR',
|
||||
TKM: 'TM',
|
||||
TCA: 'TC',
|
||||
TUV: 'TV',
|
||||
UGA: 'UG',
|
||||
UKR: 'UA',
|
||||
ARE: 'AE',
|
||||
GBR: 'GB',
|
||||
USA: 'US',
|
||||
UMI: 'UM',
|
||||
URY: 'UY',
|
||||
UZB: 'UZ',
|
||||
VUT: 'VU',
|
||||
VEN: 'VE',
|
||||
VNM: 'VN',
|
||||
VIR: 'VI',
|
||||
WLF: 'WF',
|
||||
ESH: 'EH',
|
||||
YEM: 'YE',
|
||||
ZMB: 'ZM',
|
||||
ZWE: 'ZW',
|
||||
XKX: 'XK',
|
||||
};
|
23
src/lib/crypto.ts
Normal file
23
src/lib/crypto.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { startOfMonth } from 'date-fns';
|
||||
import { hash } from 'next-basics';
|
||||
import { v4, v5, validate } from 'uuid';
|
||||
|
||||
export function secret() {
|
||||
return hash(process.env.APP_SECRET || process.env.DATABASE_URL);
|
||||
}
|
||||
|
||||
export function salt() {
|
||||
const ROTATING_SALT = hash(startOfMonth(new Date()).toUTCString());
|
||||
|
||||
return hash(secret(), ROTATING_SALT);
|
||||
}
|
||||
|
||||
export function uuid(...args: any) {
|
||||
if (!args.length) return v4();
|
||||
|
||||
return v5(hash(...args, salt()), v5.DNS);
|
||||
}
|
||||
|
||||
export function isUuid(value: string) {
|
||||
return validate(value);
|
||||
}
|
75
src/lib/data.ts
Normal file
75
src/lib/data.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { isValid, parseISO } from 'date-fns';
|
||||
import { DATA_TYPE } from './constants';
|
||||
import { DynamicDataType } from './types';
|
||||
|
||||
export function flattenJSON(
|
||||
eventData: { [key: string]: any },
|
||||
keyValues: { key: string; value: any; dynamicDataType: DynamicDataType }[] = [],
|
||||
parentKey = '',
|
||||
): { key: string; value: any; dynamicDataType: 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) && !isValid(value)) {
|
||||
flattenJSON(value, acc.keyValues, getKeyName(key, parentKey));
|
||||
} else {
|
||||
createKey(getKeyName(key, parentKey), value, acc);
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{ keyValues, parentKey },
|
||||
).keyValues;
|
||||
}
|
||||
|
||||
export function getDataType(value: any): string {
|
||||
let type: string = typeof value;
|
||||
|
||||
if ((type === 'string' && isValid(value)) || isValid(parseISO(value))) {
|
||||
type = 'date';
|
||||
}
|
||||
|
||||
return type;
|
||||
}
|
||||
|
||||
function createKey(key, value, acc: { keyValues: any[]; parentKey: string }) {
|
||||
const type = getDataType(value);
|
||||
|
||||
let dynamicDataType = null;
|
||||
|
||||
switch (type) {
|
||||
case 'number':
|
||||
dynamicDataType = DATA_TYPE.number;
|
||||
break;
|
||||
case 'string':
|
||||
dynamicDataType = DATA_TYPE.string;
|
||||
break;
|
||||
case 'boolean':
|
||||
dynamicDataType = DATA_TYPE.boolean;
|
||||
value = value ? 'true' : 'false';
|
||||
break;
|
||||
case 'date':
|
||||
dynamicDataType = DATA_TYPE.date;
|
||||
break;
|
||||
case 'object':
|
||||
dynamicDataType = DATA_TYPE.array;
|
||||
value = JSON.stringify(value);
|
||||
break;
|
||||
default:
|
||||
dynamicDataType = DATA_TYPE.string;
|
||||
break;
|
||||
}
|
||||
|
||||
acc.keyValues.push({ key, value, dynamicDataType });
|
||||
}
|
||||
|
||||
function getKeyName(key, parentKey) {
|
||||
if (!parentKey) {
|
||||
return key;
|
||||
}
|
||||
|
||||
return `${parentKey}.${key}`;
|
||||
}
|
333
src/lib/date.ts
Normal file
333
src/lib/date.ts
Normal file
@ -0,0 +1,333 @@
|
||||
import moment from 'moment-timezone';
|
||||
import {
|
||||
addMinutes,
|
||||
addHours,
|
||||
addDays,
|
||||
addMonths,
|
||||
addYears,
|
||||
subHours,
|
||||
subDays,
|
||||
subMonths,
|
||||
subYears,
|
||||
startOfMinute,
|
||||
startOfHour,
|
||||
startOfDay,
|
||||
startOfWeek,
|
||||
startOfMonth,
|
||||
startOfYear,
|
||||
endOfHour,
|
||||
endOfDay,
|
||||
endOfWeek,
|
||||
endOfMonth,
|
||||
endOfYear,
|
||||
differenceInMinutes,
|
||||
differenceInHours,
|
||||
differenceInCalendarDays,
|
||||
differenceInCalendarMonths,
|
||||
differenceInCalendarYears,
|
||||
format,
|
||||
max,
|
||||
min,
|
||||
isDate,
|
||||
subWeeks,
|
||||
} from 'date-fns';
|
||||
import { getDateLocale } from 'lib/lang';
|
||||
import { DateRange } from 'lib/types';
|
||||
|
||||
export const TIME_UNIT = {
|
||||
minute: 'minute',
|
||||
hour: 'hour',
|
||||
day: 'day',
|
||||
week: 'week',
|
||||
month: 'month',
|
||||
year: 'year',
|
||||
};
|
||||
|
||||
const dateFuncs = {
|
||||
minute: [differenceInMinutes, addMinutes, startOfMinute],
|
||||
hour: [differenceInHours, addHours, startOfHour],
|
||||
day: [differenceInCalendarDays, addDays, startOfDay],
|
||||
month: [differenceInCalendarMonths, addMonths, startOfMonth],
|
||||
year: [differenceInCalendarYears, addYears, startOfYear],
|
||||
};
|
||||
|
||||
export function getTimezone() {
|
||||
return moment.tz.guess();
|
||||
}
|
||||
|
||||
export function getLocalTime(t: string | number | Date) {
|
||||
return addMinutes(new Date(t), new Date().getTimezoneOffset());
|
||||
}
|
||||
|
||||
export function parseDateRange(value: string | object, locale = 'en-US'): DateRange {
|
||||
if (typeof value === 'object') {
|
||||
return value as DateRange;
|
||||
}
|
||||
|
||||
if (value === 'all') {
|
||||
return {
|
||||
startDate: new Date(0),
|
||||
endDate: new Date(1),
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
if (value?.startsWith?.('range')) {
|
||||
const [, startTime, endTime] = value.split(':');
|
||||
|
||||
const startDate = new Date(+startTime);
|
||||
const endDate = new Date(+endTime);
|
||||
|
||||
return {
|
||||
startDate,
|
||||
endDate,
|
||||
unit: getMinimumUnit(startDate, endDate),
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const dateLocale = getDateLocale(locale);
|
||||
|
||||
const match = value?.match?.(/^(?<num>[0-9-]+)(?<unit>hour|day|week|month|year)$/);
|
||||
|
||||
if (!match) return null;
|
||||
|
||||
const { num, unit } = match.groups;
|
||||
const selectedUnit = { num: +num, unit };
|
||||
|
||||
if (+num === 1) {
|
||||
switch (unit) {
|
||||
case 'day':
|
||||
return {
|
||||
startDate: startOfDay(now),
|
||||
endDate: endOfDay(now),
|
||||
unit: 'hour',
|
||||
value,
|
||||
selectedUnit,
|
||||
};
|
||||
case 'week':
|
||||
return {
|
||||
startDate: startOfWeek(now, { locale: dateLocale }),
|
||||
endDate: endOfWeek(now, { locale: dateLocale }),
|
||||
unit: 'day',
|
||||
value,
|
||||
selectedUnit,
|
||||
};
|
||||
case 'month':
|
||||
return {
|
||||
startDate: startOfMonth(now),
|
||||
endDate: endOfMonth(now),
|
||||
unit: 'day',
|
||||
value,
|
||||
selectedUnit,
|
||||
};
|
||||
case 'year':
|
||||
return {
|
||||
startDate: startOfYear(now),
|
||||
endDate: endOfYear(now),
|
||||
unit: 'month',
|
||||
value,
|
||||
selectedUnit,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (+num === -1) {
|
||||
switch (unit) {
|
||||
case 'day':
|
||||
return {
|
||||
startDate: subDays(startOfDay(now), 1),
|
||||
endDate: subDays(endOfDay(now), 1),
|
||||
unit: 'hour',
|
||||
value,
|
||||
selectedUnit,
|
||||
};
|
||||
case 'week':
|
||||
return {
|
||||
startDate: subDays(startOfWeek(now, { locale: dateLocale }), 7),
|
||||
endDate: subDays(endOfWeek(now, { locale: dateLocale }), 1),
|
||||
unit: 'day',
|
||||
value,
|
||||
selectedUnit,
|
||||
};
|
||||
case 'month':
|
||||
return {
|
||||
startDate: subMonths(startOfMonth(now), 1),
|
||||
endDate: subMonths(endOfMonth(now), 1),
|
||||
unit: 'day',
|
||||
value,
|
||||
selectedUnit,
|
||||
};
|
||||
case 'year':
|
||||
return {
|
||||
startDate: subYears(startOfYear(now), 1),
|
||||
endDate: subYears(endOfYear(now), 1),
|
||||
unit: 'month',
|
||||
value,
|
||||
selectedUnit,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
switch (unit) {
|
||||
case 'day':
|
||||
return {
|
||||
startDate: subDays(startOfDay(now), +num - 1),
|
||||
endDate: endOfDay(now),
|
||||
unit,
|
||||
value,
|
||||
selectedUnit,
|
||||
};
|
||||
case 'hour':
|
||||
return {
|
||||
startDate: subHours(startOfHour(now), +num - 1),
|
||||
endDate: endOfHour(now),
|
||||
unit,
|
||||
value,
|
||||
selectedUnit,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function incrementDateRange(
|
||||
value: { startDate: any; endDate: any; selectedUnit: any },
|
||||
increment: number,
|
||||
) {
|
||||
const { startDate, endDate, selectedUnit } = value;
|
||||
|
||||
const { num, unit } = selectedUnit;
|
||||
|
||||
const sub = Math.abs(num) * increment;
|
||||
|
||||
switch (unit) {
|
||||
case 'hour':
|
||||
return {
|
||||
...value,
|
||||
startDate: subHours(startDate, sub),
|
||||
endDate: subHours(endDate, sub),
|
||||
value: 'range',
|
||||
};
|
||||
case 'day':
|
||||
return {
|
||||
...value,
|
||||
startDate: subDays(startDate, sub),
|
||||
endDate: subDays(endDate, sub),
|
||||
value: 'range',
|
||||
};
|
||||
case 'week':
|
||||
return {
|
||||
...value,
|
||||
startDate: subWeeks(startDate, sub),
|
||||
endDate: subWeeks(endDate, sub),
|
||||
value: 'range',
|
||||
};
|
||||
case 'month':
|
||||
return {
|
||||
...value,
|
||||
startDate: subMonths(startDate, sub),
|
||||
endDate: subMonths(endDate, sub),
|
||||
value: 'range',
|
||||
};
|
||||
case 'year':
|
||||
return {
|
||||
...value,
|
||||
startDate: subYears(startDate, sub),
|
||||
endDate: subYears(endDate, sub),
|
||||
value: 'range',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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) <= 12) {
|
||||
return 'day';
|
||||
} else if (differenceInCalendarMonths(endDate, startDate) <= 24) {
|
||||
return 'month';
|
||||
}
|
||||
|
||||
return 'year';
|
||||
}
|
||||
|
||||
export function getDateFromString(str: string) {
|
||||
const [ymd, hms] = str.split(' ');
|
||||
const [year, month, day] = ymd.split('-');
|
||||
|
||||
if (hms) {
|
||||
const [hour, min, sec] = hms.split(':');
|
||||
|
||||
return new Date(+year, +month - 1, +day, +hour, +min, +sec);
|
||||
}
|
||||
|
||||
return new Date(+year, +month - 1, +day);
|
||||
}
|
||||
|
||||
export function getDateArray(data: any[], startDate: Date, endDate: Date, unit: string) {
|
||||
const arr = [];
|
||||
const [diff, add, normalize] = dateFuncs[unit];
|
||||
const n = diff(endDate, startDate) + 1;
|
||||
|
||||
function findData(date: Date) {
|
||||
const d = data.find(({ x }) => {
|
||||
return normalize(getDateFromString(x)).getTime() === date.getTime();
|
||||
});
|
||||
|
||||
return d?.y || 0;
|
||||
}
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const t = normalize(add(startDate, i));
|
||||
const y = findData(t);
|
||||
|
||||
arr.push({ x: t, y });
|
||||
}
|
||||
|
||||
return arr;
|
||||
}
|
||||
|
||||
export function getDateLength(startDate: Date, endDate: Date, unit: string | number) {
|
||||
const [diff] = dateFuncs[unit];
|
||||
return diff(endDate, startDate) + 1;
|
||||
}
|
||||
|
||||
export const CUSTOM_FORMATS = {
|
||||
'en-US': {
|
||||
p: 'ha',
|
||||
pp: 'h:mm:ss',
|
||||
},
|
||||
'fr-FR': {
|
||||
'M/d': 'd/M',
|
||||
'MMM d': 'd MMM',
|
||||
'EEE M/d': 'EEE d/M',
|
||||
},
|
||||
};
|
||||
|
||||
export function formatDate(date: string | number | Date, str: string, locale = 'en-US') {
|
||||
return format(
|
||||
typeof date === 'string' ? new Date(date) : date,
|
||||
CUSTOM_FORMATS?.[locale]?.[str] || str,
|
||||
{
|
||||
locale: getDateLocale(locale),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function maxDate(...args: Date[]) {
|
||||
return max(args.filter(n => isDate(n)));
|
||||
}
|
||||
|
||||
export function minDate(...args: any[]) {
|
||||
return min(args.filter(n => isDate(n)));
|
||||
}
|
45
src/lib/db.ts
Normal file
45
src/lib/db.ts
Normal file
@ -0,0 +1,45 @@
|
||||
export const PRISMA = 'prisma';
|
||||
export const POSTGRESQL = 'postgresql';
|
||||
export const MYSQL = 'mysql';
|
||||
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 && url.split(':')[0];
|
||||
|
||||
if (type === 'postgres') {
|
||||
return POSTGRESQL;
|
||||
}
|
||||
|
||||
if (process.env.CLICKHOUSE_URL) {
|
||||
return CLICKHOUSE;
|
||||
}
|
||||
|
||||
return type;
|
||||
}
|
||||
|
||||
export async function runQuery(queries: any) {
|
||||
const db = getDatabaseType(process.env.CLICKHOUSE_URL || process.env.DATABASE_URL);
|
||||
|
||||
if (db === POSTGRESQL || db === MYSQL) {
|
||||
return queries[PRISMA]();
|
||||
}
|
||||
|
||||
if (db === CLICKHOUSE) {
|
||||
if (queries[KAFKA]) {
|
||||
return queries[KAFKA]();
|
||||
}
|
||||
|
||||
return queries[CLICKHOUSE]();
|
||||
}
|
||||
}
|
||||
|
||||
export function notImplemented() {
|
||||
throw new Error('Not implemented.');
|
||||
}
|
137
src/lib/detect.ts
Normal file
137
src/lib/detect.ts
Normal file
@ -0,0 +1,137 @@
|
||||
import path from 'path';
|
||||
import { getClientIp } from 'request-ip';
|
||||
import { browserName, detectOS } from 'detect-browser';
|
||||
import isLocalhost from 'is-localhost-ip';
|
||||
import maxmind from 'maxmind';
|
||||
import { safeDecodeURIComponent } from 'next-basics';
|
||||
|
||||
import {
|
||||
DESKTOP_OS,
|
||||
MOBILE_OS,
|
||||
DESKTOP_SCREEN_WIDTH,
|
||||
LAPTOP_SCREEN_WIDTH,
|
||||
MOBILE_SCREEN_WIDTH,
|
||||
} from './constants';
|
||||
import { NextApiRequestCollect } from 'pages/api/send';
|
||||
|
||||
let lookup;
|
||||
|
||||
export function getIpAddress(req) {
|
||||
// Custom header
|
||||
if (req.headers[process.env.CLIENT_IP_HEADER]) {
|
||||
return req.headers[process.env.CLIENT_IP_HEADER];
|
||||
}
|
||||
// Cloudflare
|
||||
else if (req.headers['cf-connecting-ip']) {
|
||||
return req.headers['cf-connecting-ip'];
|
||||
}
|
||||
|
||||
return getClientIp(req);
|
||||
}
|
||||
|
||||
export function getDevice(screen, os) {
|
||||
if (!screen) return;
|
||||
|
||||
const [width] = screen.split('x');
|
||||
|
||||
if (DESKTOP_OS.includes(os)) {
|
||||
if (os === 'Chrome OS' || width < DESKTOP_SCREEN_WIDTH) {
|
||||
return 'laptop';
|
||||
}
|
||||
return 'desktop';
|
||||
} else if (MOBILE_OS.includes(os)) {
|
||||
if (os === 'Amazon OS' || width > MOBILE_SCREEN_WIDTH) {
|
||||
return 'tablet';
|
||||
}
|
||||
return 'mobile';
|
||||
}
|
||||
|
||||
if (width >= DESKTOP_SCREEN_WIDTH) {
|
||||
return 'desktop';
|
||||
} else if (width >= LAPTOP_SCREEN_WIDTH) {
|
||||
return 'laptop';
|
||||
} else if (width >= MOBILE_SCREEN_WIDTH) {
|
||||
return 'tablet';
|
||||
} else {
|
||||
return 'mobile';
|
||||
}
|
||||
}
|
||||
|
||||
function getRegionCode(country, region) {
|
||||
if (!country || !region) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return region.includes('-') ? region : `${country}-${region}`;
|
||||
}
|
||||
|
||||
export async function getLocation(ip, req) {
|
||||
// Ignore local ips
|
||||
if (await isLocalhost(ip)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cloudflare headers
|
||||
if (req.headers['cf-ipcountry']) {
|
||||
const country = safeDecodeURIComponent(req.headers['cf-ipcountry']);
|
||||
const subdivision1 = safeDecodeURIComponent(req.headers['cf-region-code']);
|
||||
const city = safeDecodeURIComponent(req.headers['cf-ipcity']);
|
||||
|
||||
return {
|
||||
country,
|
||||
subdivision1: getRegionCode(country, subdivision1),
|
||||
city,
|
||||
};
|
||||
}
|
||||
|
||||
// Vercel headers
|
||||
if (req.headers['x-vercel-ip-country']) {
|
||||
const country = safeDecodeURIComponent(req.headers['x-vercel-ip-country']);
|
||||
const subdivision1 = safeDecodeURIComponent(req.headers['x-vercel-ip-country-region']);
|
||||
const city = safeDecodeURIComponent(req.headers['x-vercel-ip-city']);
|
||||
|
||||
return {
|
||||
country,
|
||||
subdivision1: getRegionCode(country, subdivision1),
|
||||
city,
|
||||
};
|
||||
}
|
||||
|
||||
// Database lookup
|
||||
if (!lookup) {
|
||||
const dir = path.join(process.cwd(), 'geo');
|
||||
|
||||
lookup = await maxmind.open(path.resolve(dir, 'GeoLite2-City.mmdb'));
|
||||
}
|
||||
|
||||
const result = lookup.get(ip);
|
||||
|
||||
if (result) {
|
||||
const country = result.country?.iso_code ?? result?.registered_country?.iso_code;
|
||||
const subdivision1 = result.subdivisions?.[0]?.iso_code;
|
||||
const subdivision2 = result.subdivisions?.[1]?.names?.en;
|
||||
const city = result.city?.names?.en;
|
||||
|
||||
return {
|
||||
country,
|
||||
subdivision1: getRegionCode(country, subdivision1),
|
||||
subdivision2,
|
||||
city,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function getClientInfo(req: NextApiRequestCollect, { screen }) {
|
||||
const userAgent = req.headers['user-agent'];
|
||||
const ip = getIpAddress(req);
|
||||
const location = await getLocation(ip, req);
|
||||
const country = location?.country;
|
||||
const subdivision1 = location?.subdivision1;
|
||||
const subdivision2 = location?.subdivision2;
|
||||
const city = location?.city;
|
||||
const browser = browserName(userAgent);
|
||||
const os = detectOS(userAgent);
|
||||
const device = getDevice(screen, os);
|
||||
|
||||
return { userAgent, browser, os, ip, country, subdivision1, subdivision2, city, device };
|
||||
}
|
78
src/lib/filters.ts
Normal file
78
src/lib/filters.ts
Normal file
@ -0,0 +1,78 @@
|
||||
export const urlFilter = (data: any[]) => {
|
||||
const map = data.reduce((obj, { x, y }) => {
|
||||
if (x) {
|
||||
if (!obj[x]) {
|
||||
obj[x] = y;
|
||||
} else {
|
||||
obj[x] += y;
|
||||
}
|
||||
}
|
||||
|
||||
return obj;
|
||||
}, {});
|
||||
|
||||
return Object.keys(map).map(key => ({ x: key, y: map[key] }));
|
||||
};
|
||||
|
||||
export const refFilter = (data: any[]) => {
|
||||
const links = {};
|
||||
|
||||
const map = data.reduce((obj, { x, y }) => {
|
||||
let id;
|
||||
|
||||
try {
|
||||
const url = new URL(x);
|
||||
|
||||
id = url.hostname.replace(/www\./, '') || url.href;
|
||||
} catch {
|
||||
id = '';
|
||||
}
|
||||
|
||||
links[id] = x;
|
||||
|
||||
if (!obj[id]) {
|
||||
obj[id] = y;
|
||||
} else {
|
||||
obj[id] += y;
|
||||
}
|
||||
|
||||
return obj;
|
||||
}, {});
|
||||
|
||||
return Object.keys(map).map(key => ({ x: key, y: map[key], w: links[key] }));
|
||||
};
|
||||
|
||||
export const emptyFilter = (data: any[]) => {
|
||||
return data.map(item => (item.x ? item : null)).filter(n => n);
|
||||
};
|
||||
|
||||
export const percentFilter = (data: any[]) => {
|
||||
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] })),
|
||||
);
|
||||
};
|
80
src/lib/format.ts
Normal file
80
src/lib/format.ts
Normal file
@ -0,0 +1,80 @@
|
||||
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 >= 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;
|
||||
}
|
118
src/lib/kafka.ts
Normal file
118
src/lib/kafka.ts
Normal file
@ -0,0 +1,118 @@
|
||||
import dateFormat from 'dateformat';
|
||||
import debug from 'debug';
|
||||
import { Kafka, Mechanism, Producer, RecordMetadata, SASLOptions, logLevel } from 'kafkajs';
|
||||
import { KAFKA, KAFKA_PRODUCER } from 'lib/db';
|
||||
import * as tls from 'tls';
|
||||
|
||||
const log = debug('umami:kafka');
|
||||
|
||||
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 ssl: { ssl?: tls.ConnectionOptions | boolean; sasl?: SASLOptions | Mechanism } =
|
||||
username && password
|
||||
? {
|
||||
ssl: {
|
||||
checkServerIdentity: () => undefined,
|
||||
ca: [process.env.CA_CERT],
|
||||
key: process.env.CLIENT_KEY,
|
||||
cert: process.env.CLIENT_CERT,
|
||||
},
|
||||
sasl: {
|
||||
mechanism: 'plain',
|
||||
username,
|
||||
password,
|
||||
},
|
||||
}
|
||||
: {};
|
||||
|
||||
const client: Kafka = new Kafka({
|
||||
clientId: 'umami',
|
||||
brokers: brokers,
|
||||
connectionTimeout: 3000,
|
||||
logLevel: logLevel.ERROR,
|
||||
...ssl,
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
global[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') {
|
||||
global[KAFKA_PRODUCER] = producer;
|
||||
}
|
||||
|
||||
log('Kafka producer initialized');
|
||||
|
||||
return producer;
|
||||
}
|
||||
|
||||
function getDateFormat(date: Date, format?: string): string {
|
||||
return dateFormat(date, format ? format : 'UTC:yyyy-mm-dd HH:MM:ss');
|
||||
}
|
||||
|
||||
async function sendMessage(
|
||||
message: { [key: string]: string | number },
|
||||
topic: string,
|
||||
): Promise<RecordMetadata[]> {
|
||||
await connect();
|
||||
|
||||
return producer.send({
|
||||
topic,
|
||||
messages: [
|
||||
{
|
||||
value: JSON.stringify(message),
|
||||
},
|
||||
],
|
||||
acks: -1,
|
||||
});
|
||||
}
|
||||
|
||||
async function sendMessages(messages: { [key: string]: string | number }[], topic: string) {
|
||||
await connect();
|
||||
|
||||
await producer.send({
|
||||
topic,
|
||||
messages: messages.map(a => {
|
||||
return { value: JSON.stringify(a) };
|
||||
}),
|
||||
acks: 1,
|
||||
});
|
||||
}
|
||||
|
||||
async function connect(): Promise<Kafka> {
|
||||
if (!kafka) {
|
||||
kafka = process.env.KAFKA_URL && process.env.KAFKA_BROKER && (global[KAFKA] || getClient());
|
||||
|
||||
if (kafka) {
|
||||
producer = global[KAFKA_PRODUCER] || (await getProducer());
|
||||
}
|
||||
}
|
||||
|
||||
return kafka;
|
||||
}
|
||||
|
||||
export default {
|
||||
enabled,
|
||||
client: kafka,
|
||||
producer,
|
||||
log,
|
||||
connect,
|
||||
getDateFormat,
|
||||
sendMessage,
|
||||
sendMessages,
|
||||
};
|
105
src/lib/lang.ts
Normal file
105
src/lib/lang.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import {
|
||||
arSA,
|
||||
be,
|
||||
bn,
|
||||
cs,
|
||||
sk,
|
||||
da,
|
||||
de,
|
||||
el,
|
||||
enUS,
|
||||
enGB,
|
||||
es,
|
||||
fi,
|
||||
fr,
|
||||
faIR,
|
||||
he,
|
||||
hi,
|
||||
hr,
|
||||
id,
|
||||
it,
|
||||
ja,
|
||||
km,
|
||||
ko,
|
||||
lt,
|
||||
mn,
|
||||
ms,
|
||||
nb,
|
||||
nl,
|
||||
pl,
|
||||
pt,
|
||||
ptBR,
|
||||
ro,
|
||||
ru,
|
||||
sl,
|
||||
sv,
|
||||
ta,
|
||||
th,
|
||||
tr,
|
||||
uk,
|
||||
zhCN,
|
||||
zhTW,
|
||||
ca,
|
||||
hu,
|
||||
vi,
|
||||
} from 'date-fns/locale';
|
||||
|
||||
export const languages = {
|
||||
'ar-SA': { label: 'العربية', dateLocale: arSA, dir: 'rtl' },
|
||||
'be-BY': { label: 'Беларуская', dateLocale: be },
|
||||
'bn-BD': { label: 'বাংলা', dateLocale: bn },
|
||||
'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-MX': { 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' },
|
||||
'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';
|
||||
}
|
51
src/lib/load.ts
Normal file
51
src/lib/load.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import cache from 'lib/cache';
|
||||
import { getSession, getUserById, getWebsiteById } from 'queries';
|
||||
import { User, Website, Session } from '@prisma/client';
|
||||
|
||||
export async function loadWebsite(websiteId: string): Promise<Website> {
|
||||
let website;
|
||||
|
||||
if (cache.enabled) {
|
||||
website = await cache.fetchWebsite(websiteId);
|
||||
} else {
|
||||
website = await getWebsiteById(websiteId);
|
||||
}
|
||||
|
||||
if (!website || website.deletedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return website;
|
||||
}
|
||||
|
||||
export async function loadSession(sessionId: string): Promise<Session> {
|
||||
let session;
|
||||
|
||||
if (cache.enabled) {
|
||||
session = await cache.fetchSession(sessionId);
|
||||
} else {
|
||||
session = await getSession(sessionId);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function loadUser(userId: string): Promise<User> {
|
||||
let user;
|
||||
|
||||
if (cache.enabled) {
|
||||
user = await cache.fetchUser(userId);
|
||||
} else {
|
||||
user = await getUserById(userId);
|
||||
}
|
||||
|
||||
if (!user || user.deletedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
107
src/lib/middleware.ts
Normal file
107
src/lib/middleware.ts
Normal file
@ -0,0 +1,107 @@
|
||||
import cors from 'cors';
|
||||
import debug from 'debug';
|
||||
import redis from '@umami/redis-client';
|
||||
import { getAuthToken, parseShareToken } from 'lib/auth';
|
||||
import { ROLES } from 'lib/constants';
|
||||
import { secret } from 'lib/crypto';
|
||||
import { findSession } from 'lib/session';
|
||||
import {
|
||||
badRequest,
|
||||
createMiddleware,
|
||||
forbidden,
|
||||
parseSecureToken,
|
||||
tooManyRequest,
|
||||
unauthorized,
|
||||
} from 'next-basics';
|
||||
import { NextApiRequestCollect } from 'pages/api/send';
|
||||
import { getUserById } from '../queries';
|
||||
|
||||
const log = debug('umami:middleware');
|
||||
|
||||
export const useCors = createMiddleware(
|
||||
cors({
|
||||
// Cache CORS preflight request 24 hours by default
|
||||
maxAge: Number(process.env.CORS_MAX_AGE) || 86400,
|
||||
}),
|
||||
);
|
||||
|
||||
export const useSession = createMiddleware(async (req, res, next) => {
|
||||
try {
|
||||
const session = await findSession(req as NextApiRequestCollect);
|
||||
|
||||
if (!session) {
|
||||
log('useSession: Session not found');
|
||||
return badRequest(res, 'Session not found.');
|
||||
}
|
||||
|
||||
(req as any).session = session;
|
||||
} catch (e: any) {
|
||||
if (e.message === 'Usage Limit.') {
|
||||
return tooManyRequest(res, e.message);
|
||||
}
|
||||
if (e.message.startsWith('Website not found:')) {
|
||||
return forbidden(res, e.message);
|
||||
}
|
||||
return badRequest(res, e.message);
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
export const useAuth = createMiddleware(async (req, res, next) => {
|
||||
const token = getAuthToken(req);
|
||||
const payload = parseSecureToken(token, secret());
|
||||
const shareToken = await parseShareToken(req as any);
|
||||
|
||||
let user = null;
|
||||
const { userId, authKey, grant } = payload || {};
|
||||
|
||||
if (userId) {
|
||||
user = await getUserById(userId);
|
||||
} else if (redis.enabled && authKey) {
|
||||
const key = await redis.client.get(authKey);
|
||||
|
||||
if (key?.userId) {
|
||||
user = await getUserById(key.userId);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
log('useAuth:', { token, shareToken, payload, user, grant });
|
||||
}
|
||||
|
||||
if (!user?.id && !shareToken) {
|
||||
log('useAuth: User not authorized');
|
||||
return unauthorized(res);
|
||||
}
|
||||
|
||||
if (user) {
|
||||
user.isAdmin = user.role === ROLES.admin;
|
||||
}
|
||||
|
||||
(req as any).auth = {
|
||||
user,
|
||||
grant,
|
||||
token,
|
||||
shareToken,
|
||||
authKey,
|
||||
};
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
export const useValidate = async (schema, req, res) => {
|
||||
return createMiddleware(async (req: any, res, next) => {
|
||||
try {
|
||||
const rules = schema[req.method];
|
||||
|
||||
if (rules) {
|
||||
rules.validateSync({ ...req.query, ...req.body });
|
||||
}
|
||||
} catch (e: any) {
|
||||
return badRequest(res, e.message);
|
||||
}
|
||||
|
||||
next();
|
||||
})(req, res);
|
||||
};
|
233
src/lib/prisma.ts
Normal file
233
src/lib/prisma.ts
Normal file
@ -0,0 +1,233 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import prisma from '@umami/prisma-client';
|
||||
import moment from 'moment-timezone';
|
||||
import { MYSQL, POSTGRESQL, getDatabaseType } from 'lib/db';
|
||||
import { FILTER_COLUMNS, SESSION_COLUMNS, OPERATORS, DEFAULT_PAGE_SIZE } from './constants';
|
||||
import { loadWebsite } from './load';
|
||||
import { maxDate } from './date';
|
||||
import { QueryFilters, QueryOptions, SearchFilter } from './types';
|
||||
|
||||
const MYSQL_DATE_FORMATS = {
|
||||
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 POSTGRESQL_DATE_FORMATS = {
|
||||
minute: 'YYYY-MM-DD HH24:MI:00',
|
||||
hour: 'YYYY-MM-DD HH24:00:00',
|
||||
day: 'YYYY-MM-DD',
|
||||
month: 'YYYY-MM-01',
|
||||
year: 'YYYY-01-01',
|
||||
};
|
||||
|
||||
function getAddIntervalQuery(field: string, interval: string): string {
|
||||
const db = getDatabaseType(process.env.DATABASE_URL);
|
||||
|
||||
if (db === POSTGRESQL) {
|
||||
return `${field} + interval '${interval}'`;
|
||||
}
|
||||
|
||||
if (db === MYSQL) {
|
||||
return `DATE_ADD(${field}, interval ${interval})`;
|
||||
}
|
||||
}
|
||||
|
||||
function getDayDiffQuery(field1: string, field2: string): string {
|
||||
const db = getDatabaseType(process.env.DATABASE_URL);
|
||||
|
||||
if (db === POSTGRESQL) {
|
||||
return `${field1}::date - ${field2}::date`;
|
||||
}
|
||||
|
||||
if (db === MYSQL) {
|
||||
return `DATEDIFF(${field1}, ${field2})`;
|
||||
}
|
||||
}
|
||||
|
||||
function getCastColumnQuery(field: string, type: string): string {
|
||||
const db = getDatabaseType(process.env.DATABASE_URL);
|
||||
|
||||
if (db === POSTGRESQL) {
|
||||
return `${field}::${type}`;
|
||||
}
|
||||
|
||||
if (db === MYSQL) {
|
||||
return `${field}`;
|
||||
}
|
||||
}
|
||||
|
||||
function getDateQuery(field: string, unit: string, timezone?: string): string {
|
||||
const db = getDatabaseType();
|
||||
|
||||
if (db === POSTGRESQL) {
|
||||
if (timezone) {
|
||||
return `to_char(date_trunc('${unit}', ${field} at time zone '${timezone}'), '${POSTGRESQL_DATE_FORMATS[unit]}')`;
|
||||
}
|
||||
return `to_char(date_trunc('${unit}', ${field}), '${POSTGRESQL_DATE_FORMATS[unit]}')`;
|
||||
}
|
||||
|
||||
if (db === MYSQL) {
|
||||
if (timezone) {
|
||||
const tz = moment.tz(timezone).format('Z');
|
||||
|
||||
return `date_format(convert_tz(${field},'+00:00','${tz}'), '${MYSQL_DATE_FORMATS[unit]}')`;
|
||||
}
|
||||
|
||||
return `date_format(${field}, '${MYSQL_DATE_FORMATS[unit]}')`;
|
||||
}
|
||||
}
|
||||
|
||||
function getTimestampDiffQuery(field1: string, field2: string): string {
|
||||
const db = getDatabaseType();
|
||||
|
||||
if (db === POSTGRESQL) {
|
||||
return `floor(extract(epoch from (${field2} - ${field1})))`;
|
||||
}
|
||||
|
||||
if (db === MYSQL) {
|
||||
return `timestampdiff(second, ${field1}, ${field2})`;
|
||||
}
|
||||
}
|
||||
|
||||
function mapFilter(column, operator, name, type = 'varchar') {
|
||||
switch (operator) {
|
||||
case OPERATORS.equals:
|
||||
return `${column} = {{${name}::${type}}}`;
|
||||
case OPERATORS.notEquals:
|
||||
return `${column} != {{${name}::${type}}}`;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function getFilterQuery(filters: QueryFilters = {}, options: QueryOptions = {}): string {
|
||||
const query = Object.keys(filters).reduce((arr, name) => {
|
||||
const value = filters[name];
|
||||
const operator = value?.filter ?? OPERATORS.equals;
|
||||
const column = FILTER_COLUMNS[name] ?? options?.columns?.[name];
|
||||
|
||||
if (value !== undefined && column) {
|
||||
arr.push(`and ${mapFilter(column, operator, name)}`);
|
||||
|
||||
if (name === 'referrer') {
|
||||
arr.push(
|
||||
'and (website_event.referrer_domain != {{websiteDomain}} or website_event.referrer_domain is null)',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return arr;
|
||||
}, []);
|
||||
|
||||
return query.join('\n');
|
||||
}
|
||||
|
||||
function normalizeFilters(filters = {}) {
|
||||
return Object.keys(filters).reduce((obj, key) => {
|
||||
const value = filters[key];
|
||||
|
||||
obj[key] = value?.value ?? value;
|
||||
|
||||
return obj;
|
||||
}, {});
|
||||
}
|
||||
|
||||
async function parseFilters(
|
||||
websiteId: string,
|
||||
filters: QueryFilters = {},
|
||||
options: QueryOptions = {},
|
||||
) {
|
||||
const website = await loadWebsite(websiteId);
|
||||
|
||||
return {
|
||||
joinSession:
|
||||
options?.joinSession || Object.keys(filters).find(key => SESSION_COLUMNS.includes(key))
|
||||
? `inner join session on website_event.session_id = session.session_id`
|
||||
: '',
|
||||
filterQuery: getFilterQuery(filters, options),
|
||||
params: {
|
||||
...normalizeFilters(filters),
|
||||
websiteId,
|
||||
startDate: maxDate(filters.startDate, website.resetAt),
|
||||
websiteDomain: website.domain,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function rawQuery(sql: string, data: object): Promise<any> {
|
||||
const db = getDatabaseType();
|
||||
const params = [];
|
||||
|
||||
if (db !== POSTGRESQL && db !== MYSQL) {
|
||||
return Promise.reject(new Error('Unknown database.'));
|
||||
}
|
||||
|
||||
const query = sql?.replaceAll(/\{\{\s*(\w+)(::\w+)?\s*}}/g, (...args) => {
|
||||
const [, name, type] = args;
|
||||
params.push(data[name]);
|
||||
|
||||
return db === MYSQL ? '?' : `$${params.length}${type ?? ''}`;
|
||||
});
|
||||
|
||||
return prisma.rawQuery(query, params);
|
||||
}
|
||||
|
||||
function getPageFilters(filters: SearchFilter): [
|
||||
{
|
||||
orderBy: {
|
||||
[x: string]: string;
|
||||
}[];
|
||||
take: number;
|
||||
skip: number;
|
||||
},
|
||||
{
|
||||
pageSize: number;
|
||||
page: number;
|
||||
orderBy: string;
|
||||
},
|
||||
] {
|
||||
const { page = 1, pageSize = DEFAULT_PAGE_SIZE, orderBy, sortDescending = false } = filters || {};
|
||||
|
||||
return [
|
||||
{
|
||||
...(pageSize > 0 && { take: +pageSize, skip: +pageSize * (page - 1) }),
|
||||
...(orderBy && {
|
||||
orderBy: [
|
||||
{
|
||||
[orderBy]: sortDescending ? 'desc' : 'asc',
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{ page: +page, pageSize, orderBy },
|
||||
];
|
||||
}
|
||||
|
||||
function getSearchMode(): { mode?: Prisma.QueryMode } {
|
||||
const db = getDatabaseType();
|
||||
|
||||
if (db === POSTGRESQL) {
|
||||
return {
|
||||
mode: 'insensitive',
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
export default {
|
||||
...prisma,
|
||||
getAddIntervalQuery,
|
||||
getDayDiffQuery,
|
||||
getCastColumnQuery,
|
||||
getDateQuery,
|
||||
getTimestampDiffQuery,
|
||||
getFilterQuery,
|
||||
parseFilters,
|
||||
getPageFilters,
|
||||
getSearchMode,
|
||||
rawQuery,
|
||||
};
|
31
src/lib/query.ts
Normal file
31
src/lib/query.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { NextApiRequest } from 'next';
|
||||
import { getAllowedUnits, getMinimumUnit } from './date';
|
||||
import { getWebsiteDateRange } from '../queries';
|
||||
|
||||
export async function parseDateRangeQuery(req: NextApiRequest) {
|
||||
const { id: websiteId, startAt, endAt, unit } = req.query;
|
||||
|
||||
// All-time
|
||||
if (+startAt === 0 && +endAt === 1) {
|
||||
const result = await getWebsiteDateRange(websiteId as string);
|
||||
const { min, max } = result[0];
|
||||
const startDate = new Date(min);
|
||||
const endDate = new Date(max);
|
||||
|
||||
return {
|
||||
startDate,
|
||||
endDate,
|
||||
unit: getMinimumUnit(startDate, endDate),
|
||||
};
|
||||
}
|
||||
|
||||
const startDate = new Date(+startAt);
|
||||
const endDate = new Date(+endAt);
|
||||
const minUnit = getMinimumUnit(startDate, endDate);
|
||||
|
||||
return {
|
||||
startDate,
|
||||
endDate,
|
||||
unit: (getAllowedUnits(startDate, endDate).includes(unit as string) ? unit : minUnit) as string,
|
||||
};
|
||||
}
|
13
src/lib/schema.ts
Normal file
13
src/lib/schema.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import * as yup from 'yup';
|
||||
|
||||
export const dateRange = {
|
||||
startAt: yup.number().integer().required(),
|
||||
endAt: yup.number().integer().moreThan(yup.ref('startAt')).required(),
|
||||
};
|
||||
|
||||
export const pageInfo = {
|
||||
query: yup.string(),
|
||||
page: yup.number().integer().positive(),
|
||||
pageSize: yup.number().integer().positive().min(1).max(200),
|
||||
orderBy: yup.string(),
|
||||
};
|
126
src/lib/session.ts
Normal file
126
src/lib/session.ts
Normal file
@ -0,0 +1,126 @@
|
||||
import { isUuid, secret, uuid } from 'lib/crypto';
|
||||
import { getClientInfo } from 'lib/detect';
|
||||
import { parseToken } from 'next-basics';
|
||||
import { NextApiRequestCollect } from 'pages/api/send';
|
||||
import { createSession } from 'queries';
|
||||
import cache from './cache';
|
||||
import clickhouse from './clickhouse';
|
||||
import { loadSession, loadWebsite } from './load';
|
||||
|
||||
export async function findSession(req: NextApiRequestCollect): Promise<{
|
||||
id: any;
|
||||
websiteId: string;
|
||||
hostname: string;
|
||||
browser: string;
|
||||
os: any;
|
||||
device: string;
|
||||
screen: string;
|
||||
language: string;
|
||||
country: any;
|
||||
subdivision1: any;
|
||||
subdivision2: any;
|
||||
city: any;
|
||||
ownerId: string;
|
||||
}> {
|
||||
const { payload } = req.body;
|
||||
|
||||
if (!payload) {
|
||||
throw new Error('Invalid payload.');
|
||||
}
|
||||
|
||||
// Check if cache token is passed
|
||||
const cacheToken = req.headers['x-umami-cache'];
|
||||
|
||||
if (cacheToken) {
|
||||
const result = await parseToken(cacheToken, secret());
|
||||
|
||||
if (result) {
|
||||
await checkUserBlock(result?.ownerId);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Verify payload
|
||||
const { website: websiteId, hostname, screen, language } = payload;
|
||||
|
||||
// Check the hostname value for legality to eliminate dirty data
|
||||
const validHostnameRegex = /^[\w-.]+$/;
|
||||
if (!validHostnameRegex.test(hostname)) {
|
||||
throw new Error('Invalid hostname.');
|
||||
}
|
||||
|
||||
if (!isUuid(websiteId)) {
|
||||
throw new Error('Invalid website ID.');
|
||||
}
|
||||
|
||||
// Find website
|
||||
const website = await loadWebsite(websiteId);
|
||||
|
||||
if (!website) {
|
||||
throw new Error(`Website not found: ${websiteId}.`);
|
||||
}
|
||||
|
||||
await checkUserBlock(website.userId);
|
||||
|
||||
const { userAgent, browser, os, ip, country, subdivision1, subdivision2, city, device } =
|
||||
await getClientInfo(req, payload);
|
||||
|
||||
const sessionId = uuid(websiteId, hostname, ip, userAgent);
|
||||
|
||||
// Clickhouse does not require session lookup
|
||||
if (clickhouse.enabled) {
|
||||
return {
|
||||
id: sessionId,
|
||||
websiteId,
|
||||
hostname,
|
||||
browser,
|
||||
os: os as any,
|
||||
device,
|
||||
screen,
|
||||
language,
|
||||
country,
|
||||
subdivision1,
|
||||
subdivision2,
|
||||
city,
|
||||
ownerId: website.userId,
|
||||
};
|
||||
}
|
||||
|
||||
// Find session
|
||||
let session = await loadSession(sessionId);
|
||||
|
||||
// Create a session if not found
|
||||
if (!session) {
|
||||
try {
|
||||
session = await createSession({
|
||||
id: sessionId,
|
||||
websiteId,
|
||||
hostname,
|
||||
browser,
|
||||
os,
|
||||
device,
|
||||
screen,
|
||||
language,
|
||||
country,
|
||||
subdivision1,
|
||||
subdivision2,
|
||||
city,
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (!e.message.toLowerCase().includes('unique constraint')) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ...session, ownerId: website.userId };
|
||||
}
|
||||
|
||||
async function checkUserBlock(userId: string) {
|
||||
if (process.env.ENABLE_BLOCKER && (await cache.fetchUserBlock(userId))) {
|
||||
await cache.incrementUserBlock(userId);
|
||||
|
||||
throw new Error('Usage Limit.');
|
||||
}
|
||||
}
|
0
src/lib/sql.ts
Normal file
0
src/lib/sql.ts
Normal file
219
src/lib/types.ts
Normal file
219
src/lib/types.ts
Normal file
@ -0,0 +1,219 @@
|
||||
import { NextApiRequest } from 'next';
|
||||
import {
|
||||
COLLECTION_TYPE,
|
||||
DATA_TYPE,
|
||||
EVENT_TYPE,
|
||||
KAFKA_TOPIC,
|
||||
PERMISSIONS,
|
||||
REPORT_TYPES,
|
||||
ROLES,
|
||||
} from './constants';
|
||||
import * as yup from 'yup';
|
||||
import { TIME_UNIT } from './date';
|
||||
|
||||
type ObjectValues<T> = T[keyof T];
|
||||
|
||||
export type TimeUnit = ObjectValues<typeof TIME_UNIT>;
|
||||
export type Permission = ObjectValues<typeof PERMISSIONS>;
|
||||
|
||||
export type CollectionType = ObjectValues<typeof COLLECTION_TYPE>;
|
||||
export type Role = ObjectValues<typeof ROLES>;
|
||||
export type EventType = ObjectValues<typeof EVENT_TYPE>;
|
||||
export type DynamicDataType = ObjectValues<typeof DATA_TYPE>;
|
||||
export type KafkaTopic = ObjectValues<typeof KAFKA_TOPIC>;
|
||||
export type ReportType = ObjectValues<typeof REPORT_TYPES>;
|
||||
|
||||
export interface WebsiteSearchFilter extends SearchFilter {
|
||||
userId?: string;
|
||||
teamId?: string;
|
||||
includeTeams?: boolean;
|
||||
onlyTeams?: boolean;
|
||||
}
|
||||
|
||||
export interface UserSearchFilter extends SearchFilter {
|
||||
teamId?: string;
|
||||
}
|
||||
|
||||
export interface TeamSearchFilter extends SearchFilter {
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export interface ReportSearchFilter extends SearchFilter {
|
||||
userId?: string;
|
||||
websiteId?: string;
|
||||
includeTeams?: boolean;
|
||||
}
|
||||
|
||||
export interface SearchFilter {
|
||||
query?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
orderBy?: string;
|
||||
sortDescending?: boolean;
|
||||
}
|
||||
|
||||
export interface FilterResult<T> {
|
||||
data: T[];
|
||||
count: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
orderBy?: string;
|
||||
sortDescending?: boolean;
|
||||
}
|
||||
|
||||
export interface DynamicData {
|
||||
[key: string]: number | string | DynamicData | number[] | string[] | DynamicData[];
|
||||
}
|
||||
|
||||
export interface Auth {
|
||||
user?: {
|
||||
id: string;
|
||||
username: string;
|
||||
role: string;
|
||||
isAdmin: boolean;
|
||||
};
|
||||
grant?: Permission[];
|
||||
shareToken?: {
|
||||
websiteId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface YupRequest {
|
||||
GET?: yup.ObjectSchema<any>;
|
||||
POST?: yup.ObjectSchema<any>;
|
||||
PUT?: yup.ObjectSchema<any>;
|
||||
DELETE?: yup.ObjectSchema<any>;
|
||||
}
|
||||
|
||||
export interface NextApiRequestQueryBody<TQuery = any, TBody = any> extends NextApiRequest {
|
||||
auth?: Auth;
|
||||
query: TQuery & { [key: string]: string | string[] };
|
||||
body: TBody;
|
||||
headers: any;
|
||||
yup: YupRequest;
|
||||
}
|
||||
|
||||
export interface NextApiRequestAuth extends NextApiRequest {
|
||||
auth?: Auth;
|
||||
headers: any;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
password?: string;
|
||||
role: string;
|
||||
createdAt?: Date;
|
||||
}
|
||||
|
||||
export interface Website {
|
||||
id: string;
|
||||
userId: string;
|
||||
resetAt: Date;
|
||||
name: string;
|
||||
domain: string;
|
||||
shareId: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface Share {
|
||||
id: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface WebsiteActive {
|
||||
x: number;
|
||||
}
|
||||
|
||||
export interface WebsiteMetric {
|
||||
x: string;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface WebsiteEventMetric {
|
||||
x: string;
|
||||
t: string;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface WebsiteEventData {
|
||||
eventName?: string;
|
||||
fieldName: string;
|
||||
dataType: number;
|
||||
fieldValue?: string;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface WebsitePageviews {
|
||||
pageviews: {
|
||||
t: string;
|
||||
y: number;
|
||||
};
|
||||
sessions: {
|
||||
t: string;
|
||||
y: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WebsiteStats {
|
||||
pageviews: { value: number; change: number };
|
||||
uniques: { value: number; change: number };
|
||||
bounces: { value: number; change: number };
|
||||
totalTime: { value: number; change: number };
|
||||
}
|
||||
|
||||
export interface RealtimeInit {
|
||||
websites: Website[];
|
||||
token: string;
|
||||
data: RealtimeUpdate;
|
||||
}
|
||||
|
||||
export interface RealtimeUpdate {
|
||||
pageviews: any[];
|
||||
sessions: any[];
|
||||
events: any[];
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface DateRange {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
value: string;
|
||||
unit?: TimeUnit;
|
||||
selectedUnit?: { num: number; unit: TimeUnit };
|
||||
}
|
||||
|
||||
export interface QueryFilters {
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
timezone?: string;
|
||||
unit?: string;
|
||||
eventType?: number;
|
||||
url?: string;
|
||||
referrer?: string;
|
||||
title?: string;
|
||||
query?: string;
|
||||
os?: string;
|
||||
browser?: string;
|
||||
device?: string;
|
||||
country?: string;
|
||||
region?: string;
|
||||
city?: string;
|
||||
language?: string;
|
||||
event?: string;
|
||||
}
|
||||
|
||||
export interface QueryOptions {
|
||||
joinSession?: boolean;
|
||||
columns?: { [key: string]: string };
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface RealtimeData {
|
||||
pageviews: any[];
|
||||
sessions: any[];
|
||||
events: any[];
|
||||
timestamp: number;
|
||||
countries?: any[];
|
||||
visitors?: any[];
|
||||
}
|
18
src/lib/yup.ts
Normal file
18
src/lib/yup.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import moment from 'moment-timezone';
|
||||
import * as yup from 'yup';
|
||||
import { UNIT_TYPES } from './constants';
|
||||
|
||||
export const TimezoneTest = yup
|
||||
.string()
|
||||
.default('UTC')
|
||||
.test(
|
||||
'timezone',
|
||||
() => `Invalid timezone`,
|
||||
value => moment.tz.zone(value) !== null,
|
||||
);
|
||||
|
||||
export const UnitTypeTest = yup.string().test(
|
||||
'unit',
|
||||
() => `Invalid unit`,
|
||||
value => UNIT_TYPES.includes(value),
|
||||
);
|
Loading…
Reference in New Issue
Block a user