# Conflicts:
#	lib/middleware.ts
#	pages/api/users/[id]/index.ts
#	pages/api/users/index.ts
#	pages/api/websites/[id]/active.ts
#	pages/api/websites/[id]/eventdata.ts
#	pages/api/websites/[id]/events.ts
#	pages/api/websites/[id]/index.ts
#	pages/api/websites/[id]/metrics.ts
#	pages/api/websites/[id]/pageviews.ts
#	pages/api/websites/[id]/reset.ts
#	pages/api/websites/[id]/stats.ts
#	yarn.lock
This commit is contained in:
Mike Cao 2022-12-31 13:54:44 -08:00
commit f3879c92e1
212 changed files with 2642 additions and 2841 deletions

View file

@ -1,18 +0,0 @@
export function chunk(arr, size) {
const chunks = [];
let index = 0;
while (index < arr.length) {
chunks.push(arr.slice(index, size + index));
index += size;
}
return chunks;
}
export function sortArrayByMap(arr, map = [], key) {
if (!arr) return [];
if (map.length === 0) return arr;
return map.map(id => arr.find(item => item[key] === id));
}

View file

@ -1,9 +1,10 @@
import { parseSecureToken, parseToken, ensureArray } from 'next-basics';
import debug from 'debug';
import cache from 'lib/cache';
import { SHARE_TOKEN_HEADER, PERMISSIONS, ROLE_PERMISSIONS } from 'lib/constants';
import { PERMISSIONS, ROLE_PERMISSIONS, SHARE_TOKEN_HEADER } from 'lib/constants';
import { secret } from 'lib/crypto';
import { ensureArray, parseSecureToken, parseToken } from 'next-basics';
import { getTeamUser } from 'queries';
import { Auth } from './types';
const log = debug('umami:auth');
@ -48,29 +49,51 @@ export function isValidToken(token, validation) {
return false;
}
export async function canViewWebsite(userId: string, websiteId: string) {
export async function canViewWebsite({ user }: Auth, websiteId: string) {
if (user.isAdmin) {
return true;
}
const website = await cache.fetchWebsite(websiteId);
if (website.userId) {
return userId === website.userId;
return user.id === website.userId;
}
if (website.teamId) {
return getTeamUser(website.teamId, userId);
return getTeamUser(website.teamId, user.id);
}
return false;
}
export async function canUpdateWebsite(userId: string, websiteId: string) {
export async function canCreateWebsite({ user }: Auth, teamId?: string) {
if (user.isAdmin) {
return true;
}
if (teamId) {
const teamUser = await getTeamUser(teamId, user.id);
return hasPermission(teamUser.role, PERMISSIONS.websiteCreate);
}
return hasPermission(user.role, PERMISSIONS.websiteCreate);
}
export async function canUpdateWebsite({ user }: Auth, websiteId: string) {
if (user.isAdmin) {
return true;
}
const website = await cache.fetchWebsite(websiteId);
if (website.userId) {
return userId === website.userId;
return user.id === website.userId;
}
if (website.teamId) {
const teamUser = await getTeamUser(website.teamId, userId);
const teamUser = await getTeamUser(website.teamId, user.id);
return hasPermission(teamUser.role, PERMISSIONS.websiteUpdate);
}
@ -78,15 +101,19 @@ export async function canUpdateWebsite(userId: string, websiteId: string) {
return false;
}
export async function canDeleteWebsite(userId: string, websiteId: string) {
export async function canDeleteWebsite({ user }: Auth, websiteId: string) {
if (user.isAdmin) {
return true;
}
const website = await cache.fetchWebsite(websiteId);
if (website.userId) {
return userId === website.userId;
return user.id === website.userId;
}
if (website.teamId) {
const teamUser = await getTeamUser(website.teamId, userId);
const teamUser = await getTeamUser(website.teamId, user.id);
return hasPermission(teamUser.role, PERMISSIONS.websiteDelete);
}
@ -95,33 +122,69 @@ export async function canDeleteWebsite(userId: string, websiteId: string) {
}
// To-do: Implement when payments are setup.
export async function canCreateTeam(userId: string) {
return !!userId;
export async function canCreateTeam({ user }: Auth) {
if (user.isAdmin) {
return true;
}
return !!user;
}
// To-do: Implement when payments are setup.
export async function canViewTeam(userId: string, teamId) {
return getTeamUser(teamId, userId);
export async function canViewTeam({ user }: Auth, teamId: string) {
if (user.isAdmin) {
return true;
}
return getTeamUser(teamId, user.id);
}
export async function canUpdateTeam(userId: string, teamId: string) {
const teamUser = await getTeamUser(teamId, userId);
export async function canUpdateTeam({ user }: Auth, teamId: string) {
if (user.isAdmin) {
return true;
}
const teamUser = await getTeamUser(teamId, user.id);
return hasPermission(teamUser.role, PERMISSIONS.teamUpdate);
}
export async function canDeleteTeam(userId: string, teamId: string) {
const teamUser = await getTeamUser(teamId, userId);
export async function canDeleteTeam({ user }: Auth, teamId: string) {
if (user.isAdmin) {
return true;
}
const teamUser = await getTeamUser(teamId, user.id);
return hasPermission(teamUser.role, PERMISSIONS.teamDelete);
}
export async function canViewUser(userId: string, viewedUserId: string) {
return userId === viewedUserId;
export async function canCreateUser({ user }: Auth) {
return user.isAdmin;
}
export async function canUpdateUser(userId: string, viewedUserId: string) {
return userId === viewedUserId;
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[]) {

View file

@ -1,10 +1,16 @@
import { User, Website } from '@prisma/client';
import redis from 'lib/redis';
import redis from '@umami/redis-client';
import { getSession, getUser, getWebsite } from '../queries';
const DELETED = 'DELETED';
async function fetchObject(key, query) {
const obj = await redis.get(key);
if (obj === DELETED) {
return null;
}
if (!obj) {
return query().then(async data => {
if (data) {
@ -22,8 +28,8 @@ async function storeObject(key, data) {
return redis.set(key, data);
}
async function deleteObject(key) {
return redis.set(key, redis.DELETED);
async function deleteObject(key, soft = false) {
return soft ? redis.set(key, DELETED) : redis.del(key);
}
async function fetchWebsite(id): Promise<Website> {
@ -42,7 +48,7 @@ async function deleteWebsite(id) {
}
async function fetchUser(id): Promise<User> {
return fetchObject(`user:${id}`, () => getUser({ id }, true));
return fetchObject(`user:${id}`, () => getUser({ id }, { includePassword: true }));
}
async function storeUser(data) {

View file

@ -64,8 +64,8 @@ function getCommaSeparatedStringFormat(data) {
return data.map(a => `'${a}'`).join(',') || '';
}
function getBetweenDates(field, start_at, end_at) {
return `${field} between ${getDateFormat(start_at)} and ${getDateFormat(end_at)}`;
function getBetweenDates(field, startAt, endAt) {
return `${field} between ${getDateFormat(startAt)} and ${getDateFormat(endAt)}`;
}
function getJsonField(column, property) {
@ -120,11 +120,15 @@ function getFilterQuery(filters = {}, params = []) {
case 'browser':
case 'device':
case 'country':
case 'event_name':
arr.push(`and ${key}=$${params.length + 1}`);
params.push(decodeURIComponent(filter));
break;
case 'eventName':
arr.push(`and event_name=$${params.length + 1}`);
params.push(decodeURIComponent(filter));
break;
case 'referrer':
arr.push(`and referrer like $${params.length + 1}`);
params.push(`%${decodeURIComponent(filter)}%`);
@ -147,18 +151,18 @@ function getFilterQuery(filters = {}, params = []) {
}
function parseFilters(filters = {}, params = []) {
const { domain, url, event_url, referrer, os, browser, device, country, event_name, query } =
const { domain, url, eventUrl, referrer, os, browser, device, country, eventName, query } =
filters;
const pageviewFilters = { domain, url, referrer, query };
const sessionFilters = { os, browser, device, country };
const eventFilters = { url: event_url, event_name };
const eventFilters = { url: eventUrl, eventName };
return {
pageviewFilters,
sessionFilters,
eventFilters,
event: { event_name },
event: { eventName },
filterQuery: getFilterQuery(filters, params),
};
}
@ -204,7 +208,7 @@ async function findFirst(data) {
}
async function connect() {
if (!clickhouse) {
if (enabled && !clickhouse) {
clickhouse = process.env.CLICKHOUSE_URL && (global[CLICKHOUSE] || getClient());
}

View file

@ -1,14 +1,14 @@
import { getItem, setItem, removeItem } from 'next-basics';
import { AUTH_TOKEN } from './constants';
export function getAuthToken() {
export function getClientAuthToken() {
return getItem(AUTH_TOKEN);
}
export function setAuthToken(token) {
export function setClientAuthToken(token) {
setItem(AUTH_TOKEN, token);
}
export function removeAuthToken() {
export function removeClientAuthToken() {
removeItem(AUTH_TOKEN);
}

View file

@ -25,7 +25,7 @@ export const REALTIME_INTERVAL = 3000;
export const EVENT_TYPE = {
pageView: 1,
customEvent: 2,
};
} as const;
export const ROLES = {
admin: 'admin',
@ -33,7 +33,7 @@ export const ROLES = {
teamOwner: 'team-owner',
teamMember: 'team-member',
teamGuest: 'team-guest',
};
} as const;
export const PERMISSIONS = {
all: 'all',
@ -43,7 +43,7 @@ export const PERMISSIONS = {
teamCreate: 'team:create',
teamUpdate: 'team:update',
teamDelete: 'team:delete',
};
} as const;
export const ROLE_PERMISSIONS = {
[ROLES.admin]: [PERMISSIONS.all],
@ -66,7 +66,7 @@ export const ROLE_PERMISSIONS = {
PERMISSIONS.websiteDelete,
],
[ROLES.teamGuest]: [],
};
} as const;
export const THEME_COLORS = {
light: {

View file

@ -3,7 +3,7 @@ import { startOfMonth } from 'date-fns';
import { hash } from 'next-basics';
export function secret() {
return hash(process.env.HASH_SALT || process.env.DATABASE_URL);
return hash(process.env.APP_SECRET || process.env.DATABASE_URL);
}
export function salt() {

View file

@ -4,7 +4,6 @@ export const MYSQL = 'mysql';
export const CLICKHOUSE = 'clickhouse';
export const KAFKA = 'kafka';
export const KAFKA_PRODUCER = 'kafka-producer';
export const REDIS = 'redis';
// Fixes issue with converting bigint values
BigInt.prototype.toJSON = function () {

View file

@ -2,10 +2,10 @@ import { createMiddleware, unauthorized, badRequest, parseSecureToken } from 'ne
import debug from 'debug';
import cors from 'cors';
import { validate } from 'uuid';
import redis from '@umami/redis-client';
import { findSession } from 'lib/session';
import { getAuthToken, parseShareToken } from 'lib/auth';
import { secret } from 'lib/crypto';
import redis from 'lib/redis';
import { ROLES } from 'lib/constants';
import { getUser } from '../queries';
@ -17,11 +17,11 @@ export const useSession = createMiddleware(async (req, res, next) => {
const session = await findSession(req);
if (!session) {
log('useSession: Session not found.');
log('useSession: Session not found');
return badRequest(res);
}
req.session = session;
(req as any).session = session;
next();
});
@ -42,7 +42,7 @@ export const useAuth = createMiddleware(async (req, res, next) => {
log({ token, payload, user, shareToken });
if (!user && !shareToken) {
log('useAuth: User not authorized.');
log('useAuth: User not authorized');
return unauthorized(res);
}
@ -50,6 +50,6 @@ export const useAuth = createMiddleware(async (req, res, next) => {
user.isAdmin = user.role === ROLES.admin;
}
req.auth = { user, token, shareToken, key };
(req as any).auth = { user, token, shareToken, key };
next();
});

View file

@ -1,10 +1,7 @@
import { PrismaClient } from '@prisma/client';
import chalk from 'chalk';
import prisma from '@umami/prisma-client';
import moment from 'moment-timezone';
import debug from 'debug';
import { PRISMA, MYSQL, POSTGRESQL, getDatabaseType } from 'lib/db';
import { MYSQL, POSTGRESQL, getDatabaseType } from 'lib/db';
import { FILTER_IGNORED } from 'lib/constants';
import { PrismaClientOptions } from '@prisma/client/runtime';
const MYSQL_DATE_FORMATS = {
minute: '%Y-%m-%d %H:%i:00',
@ -22,39 +19,7 @@ const POSTGRESQL_DATE_FORMATS = {
year: 'YYYY-01-01',
};
const log = debug('umami:prisma');
const PRISMA_OPTIONS = {
log: [
{
emit: 'event',
level: 'query',
},
],
};
function logQuery(e) {
log(chalk.yellow(e.params), '->', e.query, chalk.greenBright(`${e.duration}ms`));
}
function getClient(options) {
const prisma: PrismaClient<PrismaClientOptions, 'query' | 'error' | 'info' | 'warn'> =
new PrismaClient(options);
if (process.env.LOG_QUERY) {
prisma.$on('query', logQuery);
}
if (process.env.NODE_ENV !== 'production') {
global[PRISMA] = prisma;
}
log('Prisma initialized');
return prisma;
}
function getDateQuery(field, unit, timezone?): string {
function getDateQuery(field: string, unit: string, timezone?: string): string {
const db = getDatabaseType(process.env.DATABASE_URL);
if (db === POSTGRESQL) {
@ -75,7 +40,7 @@ function getDateQuery(field, unit, timezone?): string {
}
}
function getTimestampInterval(field): string {
function getTimestampInterval(field: string): string {
const db = getDatabaseType(process.env.DATABASE_URL);
if (db === POSTGRESQL) {
@ -87,7 +52,7 @@ function getTimestampInterval(field): string {
}
}
function getJsonField(column, property, isNumber): string {
function getJsonField(column: string, property: string, isNumber: boolean): string {
const db = getDatabaseType(process.env.DATABASE_URL);
if (db === POSTGRESQL) {
@ -159,19 +124,26 @@ function getFilterQuery(filters = {}, params = []): string {
case 'browser':
case 'device':
case 'country':
case 'event_name':
arr.push(`and ${key}=$${params.length + 1}`);
params.push(decodeURIComponent(filter));
break;
case 'eventName':
arr.push(`and event_name=$${params.length + 1}`);
params.push(decodeURIComponent(filter));
break;
case 'referrer':
arr.push(`and referrer like $${params.length + 1}`);
params.push(`%${decodeURIComponent(filter)}%`);
break;
case 'domain':
arr.push(`and referrer not like $${params.length + 1}`);
arr.push(`and referrer not like '/%'`);
params.push(`%://${filter}/%`);
break;
case 'query':
arr.push(`and url like '%?%'`);
}
@ -187,18 +159,18 @@ function parseFilters(
params = [],
sessionKey = 'session_id',
) {
const { domain, url, event_url, referrer, os, browser, device, country, event_name, query } =
const { domain, url, eventUrl, referrer, os, browser, device, country, eventName, query } =
filters;
const pageviewFilters = { domain, url, referrer, query };
const sessionFilters = { os, browser, device, country };
const eventFilters = { url: event_url, event_name };
const eventFilters = { url: eventUrl, eventName };
return {
pageviewFilters,
sessionFilters,
eventFilters,
event: { event_name },
event: { eventName },
joinSession:
os || browser || device || country
? `inner join session on ${sessionKey} = session.${sessionKey}`
@ -207,7 +179,7 @@ function parseFilters(
};
}
async function rawQuery(query, params = []): Promise<any> {
async function rawQuery(query: string, params: never[] = []): Promise<any> {
const db = getDatabaseType(process.env.DATABASE_URL);
if (db !== POSTGRESQL && db !== MYSQL) {
@ -216,20 +188,11 @@ async function rawQuery(query, params = []): Promise<any> {
const sql = db === MYSQL ? query.replace(/\$[0-9]+/g, '?') : query;
return prisma.$queryRawUnsafe.apply(prisma, [sql, ...params]);
return prisma.rawQuery(sql, params);
}
async function transaction(queries): Promise<any> {
return prisma.$transaction(queries);
}
// Initialization
const prisma: PrismaClient<PrismaClientOptions, 'query' | 'error' | 'info' | 'warn'> =
global[PRISMA] || getClient(PRISMA_OPTIONS);
export default {
client: prisma,
log,
...prisma,
getDateQuery,
getTimestampInterval,
getFilterQuery,
@ -237,5 +200,4 @@ export default {
getEventDataFilterQuery,
parseFilters,
rawQuery,
transaction,
};

View file

@ -1,62 +0,0 @@
import { createClient } from 'redis';
import debug from 'debug';
const log = debug('umami:redis');
const REDIS = Symbol();
const DELETED = 'DELETED';
let redis;
const url = process.env.REDIS_URL;
const enabled = Boolean(url);
async function getClient() {
if (!enabled) {
return null;
}
const client = createClient({ url });
client.on('error', err => log(err));
await client.connect();
if (process.env.NODE_ENV !== 'production') {
global[REDIS] = client;
}
log('Redis initialized');
return client;
}
async function get(key) {
await connect();
const data = await redis.get(key);
try {
return JSON.parse(data);
} catch {
return null;
}
}
async function set(key, value) {
await connect();
return redis.set(key, JSON.stringify(value));
}
async function del(key) {
await connect();
return redis.del(key);
}
async function connect() {
if (!redis && enabled) {
redis = global[REDIS] || (await getClient());
}
return redis;
}
export default { enabled, client: redis, log, connect, get, set, del, DELETED };

View file

@ -3,7 +3,7 @@ import { validate } from 'uuid';
import { secret, uuid } from 'lib/crypto';
import cache from 'lib/cache';
import clickhouse from 'lib/clickhouse';
import { getClientInfo, getJsonBody } from 'lib/request';
import { getClientInfo, getJsonBody } from 'lib/detect';
import { createSession, getSession, getWebsite } from 'queries';
export async function findSession(req) {