Merge branch 'master' into hosts-support

This commit is contained in:
Mike Cao 2024-06-18 23:01:09 -07:00 committed by GitHub
commit e11c2e452c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
69 changed files with 3783 additions and 2197 deletions

View file

@ -1,82 +0,0 @@
import { User, Website } from '@prisma/client';
import redis from '@umami/redis-client';
import { getSession, getUser, getWebsite } from '../queries';
async function fetchWebsite(websiteId: string): Promise<Website> {
return redis.client.getCache(`website:${websiteId}`, () => getWebsite(websiteId), 86400);
}
async function storeWebsite(data: { id: any }) {
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}`, () => getUser(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,
};

View file

@ -4,7 +4,7 @@ import debug from 'debug';
import { CLICKHOUSE } from 'lib/db';
import { QueryFilters, QueryOptions } from './types';
import { OPERATORS } from './constants';
import { loadWebsite } from './load';
import { fetchWebsite } from './load';
import { maxDate } from './date';
import { filtersToArray } from './params';
@ -106,7 +106,7 @@ function getFilterParams(filters: QueryFilters = {}) {
}
async function parseFilters(websiteId: string, filters: QueryFilters = {}, options?: QueryOptions) {
const website = await loadWebsite(websiteId);
const website = await fetchWebsite(websiteId);
return {
filterQuery: getFilterQuery(filters, options),

View file

@ -30,7 +30,8 @@ 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 UNIT_TYPES = ['year', 'month', 'hour', 'day', 'minute'];
export const EVENT_COLUMNS = ['url', 'referrer', 'title', 'query', 'event', 'host'];
export const SESSION_COLUMNS = [
@ -134,6 +135,7 @@ export const ROLES = {
user: 'user',
viewOnly: 'view-only',
teamOwner: 'team-owner',
teamManager: 'team-manager',
teamMember: 'team-member',
teamViewOnly: 'team-view-only',
} as const;
@ -164,6 +166,12 @@ export const ROLE_PERMISSIONS = {
PERMISSIONS.websiteUpdate,
PERMISSIONS.websiteDelete,
],
[ROLES.teamManager]: [
PERMISSIONS.teamUpdate,
PERMISSIONS.websiteCreate,
PERMISSIONS.websiteUpdate,
PERMISSIONS.websiteDelete,
],
[ROLES.teamMember]: [
PERMISSIONS.websiteCreate,
PERMISSIONS.websiteUpdate,

View file

@ -1,6 +1,6 @@
import { startOfHour, startOfMonth } from 'date-fns';
import { hash } from 'next-basics';
import { v4, v5, validate } from 'uuid';
import { v4, v5 } from 'uuid';
export function secret() {
return hash(process.env.APP_SECRET || process.env.DATABASE_URL);
@ -23,7 +23,3 @@ export function uuid(...args: any) {
return v5(hash(...args, salt()), v5.DNS);
}
export function isUuid(value: string) {
return validate(value);
}

View file

@ -132,7 +132,7 @@ export async function getClientInfo(req: NextApiRequestCollect) {
const subdivision2 = location?.subdivision2;
const city = location?.city;
const browser = browserName(userAgent);
const os = detectOS(userAgent);
const os = detectOS(userAgent) as string;
const device = getDevice(req.body?.payload?.screen, os);
return { userAgent, browser, os, ip, country, subdivision1, subdivision2, city, device };

View file

@ -2,6 +2,7 @@ import {
arSA,
be,
bn,
bs,
cs,
sk,
da,
@ -48,6 +49,7 @@ export const languages = {
'ar-SA': { label: 'العربية', dateLocale: arSA, dir: 'rtl' },
'be-BY': { label: 'Беларуская', dateLocale: be },
'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 },

View file

@ -1,12 +1,12 @@
import cache from 'lib/cache';
import { getSession, getUser, getWebsite } from 'queries';
import { User, Website, Session } from '@prisma/client';
import { getSession, getWebsite } from 'queries';
import { Website, Session } from '@prisma/client';
import redis from '@umami/redis-client';
export async function loadWebsite(websiteId: string): Promise<Website> {
export async function fetchWebsite(websiteId: string): Promise<Website> {
let website;
if (cache.enabled) {
website = await cache.fetchWebsite(websiteId);
if (redis.enabled) {
website = await redis.client.fetch(`website:${websiteId}`, () => getWebsite(websiteId), 86400);
} else {
website = await getWebsite(websiteId);
}
@ -18,11 +18,11 @@ export async function loadWebsite(websiteId: string): Promise<Website> {
return website;
}
export async function loadSession(sessionId: string): Promise<Session> {
export async function fetchSession(sessionId: string): Promise<Session> {
let session;
if (cache.enabled) {
session = await cache.fetchSession(sessionId);
if (redis.enabled) {
session = await redis.client.fetch(`session:${sessionId}`, () => getSession(sessionId), 86400);
} else {
session = await getSession(sessionId);
}
@ -33,19 +33,3 @@ export async function loadSession(sessionId: string): Promise<Session> {
return session;
}
export async function loadUser(userId: string): Promise<User> {
let user;
if (cache.enabled) {
user = await cache.fetchUser(userId);
} else {
user = await getUser(userId);
}
if (!user || user.deletedAt) {
return null;
}
return user;
}

View file

@ -4,13 +4,12 @@ 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 { getSession } from 'lib/session';
import {
badRequest,
createMiddleware,
forbidden,
notFound,
parseSecureToken,
tooManyRequest,
unauthorized,
} from 'next-basics';
import { NextApiRequestCollect } from 'pages/api/send';
@ -27,7 +26,7 @@ export const useCors = createMiddleware(
export const useSession = createMiddleware(async (req, res, next) => {
try {
const session = await findSession(req as NextApiRequestCollect);
const session = await getSession(req as NextApiRequestCollect);
if (!session) {
log('useSession: Session not found');
@ -36,11 +35,8 @@ export const useSession = createMiddleware(async (req, res, next) => {
(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);
if (e.message.startsWith('Website not found')) {
return notFound(res, e.message);
}
return badRequest(res, e.message);
}

View file

@ -3,9 +3,9 @@ import prisma from '@umami/prisma-client';
import moment from 'moment-timezone';
import { MYSQL, POSTGRESQL, getDatabaseType } from 'lib/db';
import { SESSION_COLUMNS, OPERATORS, DEFAULT_PAGE_SIZE } from './constants';
import { loadWebsite } from './load';
import { fetchWebsite } from './load';
import { maxDate } from './date';
import { QueryFilters, QueryOptions, SearchFilter } from './types';
import { QueryFilters, QueryOptions, PageParams } from './types';
import { filtersToArray } from './params';
const MYSQL_DATE_FORMATS = {
@ -152,7 +152,7 @@ async function parseFilters(
filters: QueryFilters = {},
options: QueryOptions = {},
) {
const website = await loadWebsite(websiteId);
const website = await fetchWebsite(websiteId);
const joinSession = Object.keys(filters).find(key => SESSION_COLUMNS.includes(key));
return {
@ -191,7 +191,7 @@ async function rawQuery(sql: string, data: object): Promise<any> {
return prisma.rawQuery(query, params);
}
async function pagedQuery<T>(model: string, criteria: T, filters: SearchFilter) {
async function pagedQuery<T>(model: string, criteria: T, filters: PageParams) {
const { page = 1, pageSize, orderBy, sortDescending = false } = filters || {};
const size = +pageSize || DEFAULT_PAGE_SIZE;

View file

@ -2,7 +2,7 @@ import * as yup from 'yup';
export const dateRange = {
startAt: yup.number().integer().required(),
endAt: yup.number().integer().moreThan(yup.ref('startAt')).required(),
endAt: yup.number().integer().min(yup.ref('startAt')).required(),
};
export const pageInfo = {

View file

@ -1,28 +1,13 @@
import { isUuid, secret, uuid, visitSalt } from 'lib/crypto';
import { secret, uuid, visitSalt } 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';
import { fetchSession, fetchWebsite } from './load';
import { SessionData } from 'lib/types';
export async function findSession(req: NextApiRequestCollect): Promise<{
id: any;
websiteId: string;
visitId: string;
hostname: string;
browser: string;
os: any;
device: string;
screen: string;
language: string;
country: any;
subdivision1: any;
subdivision2: any;
city: any;
ownerId: string;
}> {
export async function getSession(req: NextApiRequestCollect): Promise<SessionData> {
const { payload } = req.body;
if (!payload) {
@ -35,9 +20,8 @@ export async function findSession(req: NextApiRequestCollect): Promise<{
if (cacheToken) {
const result = await parseToken(cacheToken, secret());
// Token is valid
if (result) {
await checkUserBlock(result?.ownerId);
return result;
}
}
@ -45,25 +29,13 @@ export async function findSession(req: NextApiRequestCollect): Promise<{
// 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);
const website = await fetchWebsite(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);
@ -78,7 +50,7 @@ export async function findSession(req: NextApiRequestCollect): Promise<{
visitId,
hostname,
browser,
os: os as any,
os,
device,
screen,
language,
@ -86,12 +58,11 @@ export async function findSession(req: NextApiRequestCollect): Promise<{
subdivision1,
subdivision2,
city,
ownerId: website.userId,
};
}
// Find session
let session = await loadSession(sessionId);
let session = await fetchSession(sessionId);
// Create a session if not found
if (!session) {
@ -117,13 +88,5 @@ export async function findSession(req: NextApiRequestCollect): Promise<{
}
}
return { ...session, ownerId: website.userId, visitId: visitId };
}
async function checkUserBlock(userId: string) {
if (process.env.ENABLE_BLOCKER && (await cache.fetchUserBlock(userId))) {
await cache.incrementUserBlock(userId);
throw new Error('Usage Limit.');
}
return { ...session, visitId: visitId };
}

View file

@ -24,31 +24,7 @@ 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 TeamUserSearchFilter extends SearchFilter {
teamId?: string;
}
export interface ReportSearchFilter extends SearchFilter {
userId?: string;
websiteId?: string;
}
export interface SearchFilter {
export interface PageParams {
query?: string;
page?: number;
pageSize?: number;
@ -56,7 +32,7 @@ export interface SearchFilter {
sortDescending?: boolean;
}
export interface FilterResult<T> {
export interface PageResult<T> {
data: T;
count: number;
page: number;
@ -66,10 +42,10 @@ export interface FilterResult<T> {
}
export interface FilterQueryResult<T> {
result: FilterResult<T>;
result: PageResult<T>;
query: any;
params: SearchFilter;
setParams: Dispatch<SetStateAction<T | SearchFilter>>;
params: PageParams;
setParams: Dispatch<SetStateAction<T | PageParams>>;
}
export interface DynamicData {
@ -230,3 +206,19 @@ export interface RealtimeData {
countries?: any[];
visitors?: any[];
}
export interface SessionData {
id: string;
websiteId: string;
visitId: string;
hostname: string;
browser: string;
os: string;
device: string;
screen: string;
language: string;
country: string;
subdivision1: string;
subdivision2: string;
city: string;
}