diff --git a/next-env.d.ts b/next-env.d.ts index 1b3be084..40c3d680 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -2,4 +2,4 @@ /// // NOTE: This file should not be edited -// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. +// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/package.json b/package.json index 4fecc230..3693ecf4 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "@tanstack/react-query": "^5.28.6", "@umami/prisma-client": "^0.14.0", "@umami/redis-client": "^0.24.0", + "bcryptjs": "^2.4.3", "chalk": "^4.1.1", "chart.js": "^4.4.2", "chartjs-adapter-date-fns": "^3.0.0", @@ -97,14 +98,15 @@ "is-docker": "^3.0.0", "is-localhost-ip": "^1.4.0", "isbot": "^5.1.16", + "jsonwebtoken": "^9.0.2", "kafkajs": "^2.1.0", "maxmind": "^4.3.6", "md5": "^2.3.0", "next": "15.0.4", - "next-basics": "^0.39.0", "node-fetch": "^3.2.8", "npm-run-all": "^4.1.5", "prisma": "6.1.0", + "pure-rand": "^6.1.0", "react": "^19.0.0", "react-basics": "^0.126.0", "react-dom": "^19.0.0", diff --git a/scripts/change-password.js b/scripts/change-password.js deleted file mode 100644 index b12373a9..00000000 --- a/scripts/change-password.js +++ /dev/null @@ -1,94 +0,0 @@ -/* eslint-disable no-console */ -require('dotenv').config(); -const { hashPassword } = require('next-basics'); -const chalk = require('chalk'); -const prompts = require('prompts'); -const { PrismaClient } = require('@prisma/client'); - -const prisma = new PrismaClient(); - -const runQuery = async query => { - return query.catch(e => { - throw e; - }); -}; - -const updateUserByUsername = (username, data) => { - return runQuery( - prisma.user.update({ - where: { - username, - }, - data, - }), - ); -}; - -const changePassword = async (username, newPassword) => { - const password = hashPassword(newPassword); - return updateUserByUsername(username, { password }); -}; - -const getUsernameAndPassword = async () => { - let [username, password] = process.argv.slice(2); - if (username && password) { - return { username, password }; - } - - const questions = []; - if (!username) { - questions.push({ - type: 'text', - name: 'username', - message: 'Enter user to change password', - }); - } - if (!password) { - questions.push( - { - type: 'password', - name: 'password', - message: 'Enter new password', - }, - { - type: 'password', - name: 'confirmation', - message: 'Confirm new password', - }, - ); - } - - const answers = await prompts(questions); - if (answers.password !== answers.confirmation) { - throw new Error(`Passwords don't match`); - } - - return { - username: username || answers.username, - password: answers.password, - }; -}; - -(async () => { - let username, password; - - try { - ({ username, password } = await getUsernameAndPassword()); - } catch (error) { - console.log(chalk.redBright(error.message)); - return; - } - - try { - await changePassword(username, password); - console.log('Password changed for user', chalk.greenBright(username)); - } catch (error) { - if (error.meta.cause.includes('Record to update not found')) { - console.log('User not found:', chalk.redBright(username)); - } else { - throw error; - } - } - - prisma.$disconnect(); -})(); diff --git a/src/app/(main)/NavBar.tsx b/src/app/(main)/NavBar.tsx index 5c8bba01..a4c70662 100644 --- a/src/app/(main)/NavBar.tsx +++ b/src/app/(main)/NavBar.tsx @@ -1,4 +1,5 @@ 'use client'; +import { useEffect } from 'react'; import { Icon, Text } from 'react-basics'; import Link from 'next/link'; import classNames from 'classnames'; @@ -9,9 +10,8 @@ import ProfileButton from 'components/input/ProfileButton'; import TeamsButton from 'components/input/TeamsButton'; import Icons from 'components/icons'; import { useMessages, useNavigation, useTeamUrl } from 'components/hooks'; +import { getItem, setItem } from 'lib/storage'; import styles from './NavBar.module.css'; -import { useEffect } from 'react'; -import { getItem, setItem } from 'next-basics'; export function NavBar() { const { formatMessage, labels } = useMessages(); diff --git a/src/app/(main)/UpdateNotice.tsx b/src/app/(main)/UpdateNotice.tsx index 553e1138..338d2f14 100644 --- a/src/app/(main)/UpdateNotice.tsx +++ b/src/app/(main)/UpdateNotice.tsx @@ -1,7 +1,7 @@ import { useEffect, useCallback, useState } from 'react'; import { createPortal } from 'react-dom'; import { Button } from 'react-basics'; -import { setItem } from 'next-basics'; +import { setItem } from 'lib/storage'; import useStore, { checkVersion } from 'store/version'; import { REPO_URL, VERSION_CHECK } from 'lib/constants'; import { useMessages } from 'components/hooks'; diff --git a/src/app/(main)/settings/websites/[websiteId]/ShareUrl.tsx b/src/app/(main)/settings/websites/[websiteId]/ShareUrl.tsx index 465492b2..1e53e9c3 100644 --- a/src/app/(main)/settings/websites/[websiteId]/ShareUrl.tsx +++ b/src/app/(main)/settings/websites/[websiteId]/ShareUrl.tsx @@ -9,7 +9,7 @@ import { LoadingButton, } from 'react-basics'; import { useContext, useState } from 'react'; -import { getRandomChars } from 'next-basics'; +import { getRandomChars } from 'lib/crypto'; import { useApi, useMessages, useModified } from 'components/hooks'; import { WebsiteContext } from 'app/(main)/websites/[websiteId]/WebsiteProvider'; diff --git a/src/app/(main)/teams/[teamId]/settings/team/TeamEditForm.tsx b/src/app/(main)/teams/[teamId]/settings/team/TeamEditForm.tsx index c2029ca6..19204965 100644 --- a/src/app/(main)/teams/[teamId]/settings/team/TeamEditForm.tsx +++ b/src/app/(main)/teams/[teamId]/settings/team/TeamEditForm.tsx @@ -9,7 +9,7 @@ import { Flexbox, useToasts, } from 'react-basics'; -import { getRandomChars } from 'next-basics'; +import { getRandomChars } from 'lib/crypto'; import { useContext, useRef, useState } from 'react'; import { useApi, useMessages, useModified } from 'components/hooks'; import { TeamContext } from 'app/(main)/teams/[teamId]/TeamProvider'; diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.tsx b/src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.tsx index f40be9db..103b5eba 100644 --- a/src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.tsx +++ b/src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.tsx @@ -6,7 +6,6 @@ import Icons from 'components/icons'; import { BROWSERS, OS_NAMES } from 'lib/constants'; import { stringToColor } from 'lib/format'; import { RealtimeData } from 'lib/types'; -import { safeDecodeURI } from 'next-basics'; import { useContext, useMemo, useState } from 'react'; import { Icon, SearchField, StatusLight, Text } from 'react-basics'; import { FixedSizeList } from 'react-window'; @@ -99,7 +98,7 @@ export function RealtimeLog({ data }: { data: RealtimeData }) { target="_blank" rel="noreferrer noopener" > - {safeDecodeURI(url)} + {url} ); } diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 42d71fcf..f972fa05 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; -import { checkPassword, createSecureToken } from 'next-basics'; +import { checkPassword } from 'lib/auth'; +import { createSecureToken } from 'lib/jwt'; import { redisEnabled } from '@umami/redis-client'; import { getUserByUsername } from 'queries'; import { json, unauthorized } from 'lib/response'; diff --git a/src/app/api/me/password/route.ts b/src/app/api/me/password/route.ts index 39af3d0e..b4089bf4 100644 --- a/src/app/api/me/password/route.ts +++ b/src/app/api/me/password/route.ts @@ -1,8 +1,8 @@ import { z } from 'zod'; +import { checkPassword, hashPassword } from 'lib/auth'; import { parseRequest } from 'lib/request'; import { json, badRequest } from 'lib/response'; import { getUser, updateUser } from 'queries/prisma/user'; -import { checkPassword, hashPassword } from 'next-basics'; export async function POST(request: Request) { const schema = z.object({ diff --git a/src/app/api/send/route.ts b/src/app/api/send/route.ts index 0db93e85..dd3253ba 100644 --- a/src/app/api/send/route.ts +++ b/src/app/api/send/route.ts @@ -1,14 +1,15 @@ import { z } from 'zod'; import { isbot } from 'isbot'; -import { createToken, parseToken, safeDecodeURI } from 'next-basics'; +import { createToken, parseToken } from 'lib/jwt'; +import { safeDecodeURI } from 'lib/url'; import clickhouse from 'lib/clickhouse'; import { parseRequest } from 'lib/request'; import { badRequest, json, forbidden, serverError } from 'lib/response'; import { fetchSession, fetchWebsite } from 'lib/load'; import { getClientInfo, hasBlockedIp } from 'lib/detect'; import { secret, uuid, visitSalt } from 'lib/crypto'; -import { createSession, saveEvent, saveSessionData } from 'queries'; import { COLLECTION_TYPE } from 'lib/constants'; +import { createSession, saveEvent, saveSessionData } from 'queries'; export async function POST(request: Request) { // Bot check diff --git a/src/app/api/share/[shareId]/route.ts b/src/app/api/share/[shareId]/route.ts index f5c5ab5a..934d51a1 100644 --- a/src/app/api/share/[shareId]/route.ts +++ b/src/app/api/share/[shareId]/route.ts @@ -1,7 +1,7 @@ import { json, notFound } from 'lib/response'; -import { getSharedWebsite } from 'queries'; -import { createToken } from 'next-basics'; +import { createToken } from 'lib/jwt'; import { secret } from 'lib/crypto'; +import { getSharedWebsite } from 'queries'; export async function GET(request: Request, { params }: { params: Promise<{ shareId: string }> }) { const { shareId } = await params; diff --git a/src/app/api/teams/route.ts b/src/app/api/teams/route.ts index 2eb0c8d8..11be6c5f 100644 --- a/src/app/api/teams/route.ts +++ b/src/app/api/teams/route.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { getRandomChars } from 'next-basics'; +import { getRandomChars } from 'lib/crypto'; import { unauthorized, json } from 'lib/response'; import { canCreateTeam } from 'lib/auth'; import { uuid } from 'lib/crypto'; diff --git a/src/app/api/users/[userId]/route.ts b/src/app/api/users/[userId]/route.ts index 0955fc7c..6bf776f3 100644 --- a/src/app/api/users/[userId]/route.ts +++ b/src/app/api/users/[userId]/route.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { canUpdateUser, canViewUser, canDeleteUser } from 'lib/auth'; import { getUser, getUserByUsername, updateUser, deleteUser } from 'queries'; import { json, unauthorized, badRequest, ok } from 'lib/response'; -import { hashPassword } from 'next-basics'; +import { hashPassword } from 'lib/auth'; import { parseRequest } from 'lib/request'; export async function GET(request: Request, { params }: { params: Promise<{ userId: string }> }) { diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts index 8f9e5723..06c95b5e 100644 --- a/src/app/api/users/route.ts +++ b/src/app/api/users/route.ts @@ -1,6 +1,5 @@ import { z } from 'zod'; -import { hashPassword } from 'next-basics'; -import { canCreateUser } from 'lib/auth'; +import { hashPassword, canCreateUser } from 'lib/auth'; import { ROLES } from 'lib/constants'; import { uuid } from 'lib/crypto'; import { parseRequest } from 'lib/request'; diff --git a/src/components/common/FilterLink.tsx b/src/components/common/FilterLink.tsx index ef278ed2..cdb928d8 100644 --- a/src/components/common/FilterLink.tsx +++ b/src/components/common/FilterLink.tsx @@ -1,6 +1,5 @@ import classNames from 'classnames'; import { useMessages, useNavigation } from 'components/hooks'; -import { safeDecodeURIComponent } from 'next-basics'; import Link from 'next/link'; import { ReactNode } from 'react'; import { Icon, Icons } from 'react-basics'; @@ -39,7 +38,7 @@ export function FilterLink({ {!value && `(${label || formatMessage(labels.unknown)})`} {value && ( - {safeDecodeURIComponent(label || value)} + {label || value} )} {externalUrl && ( diff --git a/src/components/hooks/queries/useTeams.ts b/src/components/hooks/queries/useTeams.ts index e5197c97..d09e2f7d 100644 --- a/src/components/hooks/queries/useTeams.ts +++ b/src/components/hooks/queries/useTeams.ts @@ -11,6 +11,7 @@ export function useTeams(userId: string) { queryFn: (params: any) => { return get(`/users/${userId}/teams`, params); }, + enabled: !!userId, }); } diff --git a/src/components/hooks/useApi.ts b/src/components/hooks/useApi.ts index e806d37e..e71d5618 100644 --- a/src/components/hooks/useApi.ts +++ b/src/components/hooks/useApi.ts @@ -1,7 +1,8 @@ +import { useCallback } from 'react'; import * as reactQuery from '@tanstack/react-query'; -import { useApi as nextUseApi } from 'next-basics'; import { getClientAuthToken } from 'lib/client'; import { SHARE_TOKEN_HEADER } from 'lib/constants'; +import { httpGet, httpPost, httpPut, httpDelete } from 'lib/fetch'; import useStore from 'store/app'; const selector = (state: { shareToken: { token?: string } }) => state.shareToken; @@ -9,12 +10,50 @@ const selector = (state: { shareToken: { token?: string } }) => state.shareToken export function useApi() { const shareToken = useStore(selector); - const { get, post, put, del } = nextUseApi( - { authorization: `Bearer ${getClientAuthToken()}`, [SHARE_TOKEN_HEADER]: shareToken?.token }, - process.env.basePath, - ); + const defaultHeaders = { + authorization: `Bearer ${getClientAuthToken()}`, + [SHARE_TOKEN_HEADER]: shareToken?.token, + }; + const basePath = process.env.basePath; - return { get, post, put, del, ...reactQuery }; + function getUrl(url: string, basePath = ''): string { + return url.startsWith('http') ? url : `${basePath}/api${url}`; + } + + const getHeaders = (headers: any = {}) => { + return { ...defaultHeaders, ...headers }; + }; + + return { + get: useCallback( + async (url: string, params: object = {}, headers: object = {}) => { + return httpGet(getUrl(url, basePath), params, getHeaders(headers)); + }, + [httpGet], + ), + + post: useCallback( + async (url: string, params: object = {}, headers: object = {}) => { + return httpPost(getUrl(url, basePath), params, getHeaders(headers)); + }, + [httpPost], + ), + + put: useCallback( + async (url: string, params: object = {}, headers: object = {}) => { + return httpPut(getUrl(url, basePath), params, getHeaders(headers)); + }, + [httpPut], + ), + + del: useCallback( + async (url: string, params: object = {}, headers: object = {}) => { + return httpDelete(getUrl(url, basePath), params, getHeaders(headers)); + }, + [httpDelete], + ), + ...reactQuery, + }; } export default useApi; diff --git a/src/components/hooks/useCountryNames.ts b/src/components/hooks/useCountryNames.ts index 2bdaa94e..8581eedf 100644 --- a/src/components/hooks/useCountryNames.ts +++ b/src/components/hooks/useCountryNames.ts @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { httpGet } from 'next-basics'; +import { httpGet } from 'lib/fetch'; import enUS from '../../../public/intl/country/en-US.json'; const countryNames = { diff --git a/src/components/hooks/useDateRange.ts b/src/components/hooks/useDateRange.ts index 23cb6e70..85aed285 100644 --- a/src/components/hooks/useDateRange.ts +++ b/src/components/hooks/useDateRange.ts @@ -1,5 +1,5 @@ import { getMinimumUnit, parseDateRange } from 'lib/date'; -import { setItem } from 'next-basics'; +import { setItem } from 'lib/storage'; import { DATE_RANGE_CONFIG, DEFAULT_DATE_COMPARE, DEFAULT_DATE_RANGE } from 'lib/constants'; import websiteStore, { setWebsiteDateRange, setWebsiteDateCompare } from 'store/websites'; import appStore, { setDateRange } from 'store/app'; diff --git a/src/components/hooks/useLanguageNames.ts b/src/components/hooks/useLanguageNames.ts index 07b36a2c..847105c1 100644 --- a/src/components/hooks/useLanguageNames.ts +++ b/src/components/hooks/useLanguageNames.ts @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { httpGet } from 'next-basics'; +import { httpGet } from 'lib/fetch'; import enUS from '../../../public/intl/language/en-US.json'; const languageNames = { diff --git a/src/components/hooks/useLocale.ts b/src/components/hooks/useLocale.ts index 69e7cc41..1ac8945e 100644 --- a/src/components/hooks/useLocale.ts +++ b/src/components/hooks/useLocale.ts @@ -1,5 +1,6 @@ import { useEffect } from 'react'; -import { httpGet, setItem } from 'next-basics'; +import { httpGet } from 'lib/fetch'; +import { setItem } from 'lib/storage'; import { LOCALE_CONFIG } from 'lib/constants'; import { getDateLocale, getTextDirection } from 'lib/lang'; import useStore, { setLocale } from 'store/app'; diff --git a/src/components/hooks/useNavigation.ts b/src/components/hooks/useNavigation.ts index a2c1167a..2c5dddc4 100644 --- a/src/components/hooks/useNavigation.ts +++ b/src/components/hooks/useNavigation.ts @@ -1,6 +1,6 @@ import { useMemo } from 'react'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; -import { buildUrl, safeDecodeURIComponent } from 'next-basics'; +import { buildUrl } from 'lib/url'; export function useNavigation(): { pathname: string; @@ -16,7 +16,7 @@ export function useNavigation(): { const obj = {}; for (const [key, value] of params.entries()) { - obj[key] = safeDecodeURIComponent(value); + obj[key] = value; } return obj; diff --git a/src/components/hooks/useTheme.ts b/src/components/hooks/useTheme.ts index aa2b1d38..c8e397d5 100644 --- a/src/components/hooks/useTheme.ts +++ b/src/components/hooks/useTheme.ts @@ -1,6 +1,6 @@ import { useEffect, useMemo } from 'react'; import useStore, { setTheme } from 'store/app'; -import { getItem, setItem } from 'next-basics'; +import { getItem, setItem } from 'lib/storage'; import { DEFAULT_THEME, THEME_COLORS, THEME_CONFIG } from 'lib/constants'; import { colord } from 'colord'; diff --git a/src/components/hooks/useTimezone.ts b/src/components/hooks/useTimezone.ts index c74f513f..4dfecdb4 100644 --- a/src/components/hooks/useTimezone.ts +++ b/src/components/hooks/useTimezone.ts @@ -1,4 +1,4 @@ -import { setItem } from 'next-basics'; +import { setItem } from 'lib/storage'; import { TIMEZONE_CONFIG } from 'lib/constants'; import { formatInTimeZone, zonedTimeToUtc, utcToZonedTime } from 'date-fns-tz'; import useStore, { setTimezone } from 'store/app'; diff --git a/src/components/input/TeamsButton.tsx b/src/components/input/TeamsButton.tsx index 1f6270b4..b4e2f157 100644 --- a/src/components/input/TeamsButton.tsx +++ b/src/components/input/TeamsButton.tsx @@ -16,7 +16,7 @@ export function TeamsButton({ }) { const { user } = useLogin(); const { formatMessage, labels } = useMessages(); - const { result } = useTeams(user?.id); + const { result } = useTeams(user.id); const { teamId } = useTeamUrl(); const team = result?.data?.find(({ id }) => id === teamId); diff --git a/src/components/metrics/Legend.tsx b/src/components/metrics/Legend.tsx index 4ebcf4b4..77442957 100644 --- a/src/components/metrics/Legend.tsx +++ b/src/components/metrics/Legend.tsx @@ -1,5 +1,4 @@ import { StatusLight } from 'react-basics'; -import { safeDecodeURIComponent } from 'next-basics'; import { colord } from 'colord'; import classNames from 'classnames'; import { LegendItem } from 'chart.js/auto'; @@ -28,9 +27,7 @@ export function Legend({ className={classNames(styles.label, { [styles.hidden]: hidden })} onClick={() => onClick(item)} > - - {safeDecodeURIComponent(text)} - + {text} ); })} diff --git a/src/components/metrics/QueryParametersTable.tsx b/src/components/metrics/QueryParametersTable.tsx index f0d08ecf..4ee15ae5 100644 --- a/src/components/metrics/QueryParametersTable.tsx +++ b/src/components/metrics/QueryParametersTable.tsx @@ -1,5 +1,4 @@ import { useState } from 'react'; -import { safeDecodeURI } from 'next-basics'; import FilterButtons from 'components/common/FilterButtons'; import { emptyFilter, paramFilter } from 'lib/filters'; import { FILTER_RAW, FILTER_COMBINED } from 'lib/constants'; @@ -39,8 +38,8 @@ export function QueryParametersTable({ x ) : (
-
{safeDecodeURI(p)}
-
{safeDecodeURI(v)}
+
{p}
+
{v}
) } diff --git a/src/declaration.d.ts b/src/declaration.d.ts index 986adf27..7dff68b8 100644 --- a/src/declaration.d.ts +++ b/src/declaration.d.ts @@ -1,5 +1,6 @@ +declare module 'bcryptjs'; +declare module 'chartjs-adapter-date-fns'; declare module 'cors'; declare module 'debug'; -declare module 'chartjs-adapter-date-fns'; +declare module 'jsonwebtoken'; declare module 'md5'; -declare module 'request-ip'; diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 34ab49b9..9eb57809 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,26 +1,30 @@ +import bcrypt from 'bcryptjs'; import { Report } from '@prisma/client'; import { getClient, redisEnabled } from '@umami/redis-client'; import debug from 'debug'; import { PERMISSIONS, ROLE_PERMISSIONS, ROLES, SHARE_TOKEN_HEADER } from 'lib/constants'; -import { secret } from 'lib/crypto'; -import { NextApiRequest } from 'next'; -import { - createSecureToken, - ensureArray, - getRandomChars, - parseSecureToken, - parseToken, -} from 'next-basics'; +import { secret, getRandomChars } from 'lib/crypto'; +import { createSecureToken, parseSecureToken, parseToken } from 'lib/jwt'; +import { ensureArray } from 'lib/utils'; import { getTeamUser, getUser, getWebsite } from 'queries'; import { Auth } from './types'; const log = debug('umami:auth'); const cloudMode = process.env.CLOUD_MODE; +const SALT_ROUNDS = 10; + +export function hashPassword(password: string, rounds = SALT_ROUNDS) { + return bcrypt.hashSync(password, rounds); +} + +export function checkPassword(password: string, passwordHash: string) { + return bcrypt.compareSync(password, passwordHash); +} export async function checkAuth(request: Request) { const token = request.headers.get('authorization')?.split(' ')?.[1]; const payload = parseSecureToken(token, secret()); - const shareToken = await parseShareToken(request as any); + const shareToken = await parseShareToken(request.headers); let user = null; const { userId, authKey, grant } = payload || {}; @@ -73,17 +77,9 @@ export async function saveAuth(data: any, expire = 0) { return createSecureToken({ authKey }, secret()); } -export function getAuthToken(req: NextApiRequest) { +export function parseShareToken(headers: Headers) { try { - return req.headers.authorization.split(' ')[1]; - } catch { - return null; - } -} - -export function parseShareToken(req: Request) { - try { - return parseToken(req.headers[SHARE_TOKEN_HEADER], secret()); + return parseToken(headers.get(SHARE_TOKEN_HEADER), secret()); } catch (e) { log(e); return null; diff --git a/src/lib/client.ts b/src/lib/client.ts index 7810c44a..b1e74021 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -1,4 +1,4 @@ -import { getItem, setItem, removeItem } from 'next-basics'; +import { getItem, setItem, removeItem } from 'lib/storage'; import { AUTH_TOKEN } from './constants'; export function getClientAuthToken() { diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts index 689efe62..a4ff3a52 100644 --- a/src/lib/crypto.ts +++ b/src/lib/crypto.ts @@ -1,7 +1,78 @@ +import crypto from 'crypto'; import { startOfHour, startOfMonth } from 'date-fns'; -import { hash } from 'next-basics'; +import prand from 'pure-rand'; import { v4, v5 } from 'uuid'; +const ALGORITHM = 'aes-256-gcm'; +const IV_LENGTH = 16; +const SALT_LENGTH = 64; +const TAG_LENGTH = 16; +const TAG_POSITION = SALT_LENGTH + IV_LENGTH; +const ENC_POSITION = TAG_POSITION + TAG_LENGTH; + +const HASH_ALGO = 'sha512'; +const HASH_ENCODING = 'hex'; + +const seed = Date.now() ^ (Math.random() * 0x100000000); +const rng = prand.xoroshiro128plus(seed); + +export function random(min: number, max: number) { + return prand.unsafeUniformIntDistribution(min, max, rng); +} + +export function getRandomChars( + n: number, + chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', +) { + const arr = chars.split(''); + let s = ''; + for (let i = 0; i < n; i++) { + s += arr[random(0, arr.length - 1)]; + } + return s; +} + +const getKey = (password: string, salt: Buffer) => + crypto.pbkdf2Sync(password, salt, 10000, 32, 'sha512'); + +export function encrypt(value: any, secret: any) { + const iv = crypto.randomBytes(IV_LENGTH); + const salt = crypto.randomBytes(SALT_LENGTH); + const key = getKey(secret, salt); + + const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + + const encrypted = Buffer.concat([cipher.update(String(value), 'utf8'), cipher.final()]); + + const tag = cipher.getAuthTag(); + + return Buffer.concat([salt, iv, tag, encrypted]).toString('base64'); +} + +export function decrypt(value: any, secret: any) { + const str = Buffer.from(String(value), 'base64'); + const salt = str.subarray(0, SALT_LENGTH); + const iv = str.subarray(SALT_LENGTH, TAG_POSITION); + const tag = str.subarray(TAG_POSITION, ENC_POSITION); + const encrypted = str.subarray(ENC_POSITION); + + const key = getKey(secret, salt); + + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); + + decipher.setAuthTag(tag); + + return decipher.update(encrypted) + decipher.final('utf8'); +} + +export function hash(...args: string[]) { + return crypto.createHash(HASH_ALGO).update(args.join('')).digest(HASH_ENCODING); +} + +export function md5(...args: string[]) { + return crypto.createHash('md5').update(args.join('')).digest('hex'); +} + export function secret() { return hash(process.env.APP_SECRET || process.env.DATABASE_URL); } diff --git a/src/lib/fetch.ts b/src/lib/fetch.ts new file mode 100644 index 00000000..9d947a13 --- /dev/null +++ b/src/lib/fetch.ts @@ -0,0 +1,31 @@ +import { buildUrl } from 'lib/url'; + +export async function request(method: string, url: string, body?: string, headers: object = {}) { + const res = await fetch(url, { + method, + cache: 'no-cache', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...headers, + }, + body, + }); + return res.json(); +} + +export function httpGet(url: string, params: object = {}, headers: object = {}) { + return request('GET', buildUrl(url, params), undefined, headers); +} + +export function httpDelete(url: string, params: object = {}, headers: object = {}) { + return request('DELETE', buildUrl(url, params), undefined, headers); +} + +export function httpPost(url: string, params: object = {}, headers: object = {}) { + return request('POST', url, JSON.stringify(params), headers); +} + +export function httpPut(url: string, params: object = {}, headers: object = {}) { + return request('PUT', url, JSON.stringify(params), headers); +} diff --git a/src/lib/jwt.ts b/src/lib/jwt.ts new file mode 100644 index 00000000..8b581be6 --- /dev/null +++ b/src/lib/jwt.ts @@ -0,0 +1,36 @@ +import jwt from 'jsonwebtoken'; +import { decrypt, encrypt } from 'lib/crypto'; + +export function createToken(payload: any, secret: any, options?: any) { + return jwt.sign(payload, secret, options); +} + +export function parseToken(token: string, secret: any) { + try { + return jwt.verify(token, secret); + } catch { + return null; + } +} + +export function createSecureToken(payload: any, secret: any, options?: any) { + return encrypt(createToken(payload, secret, options), secret); +} + +export function parseSecureToken(token: string, secret: any) { + try { + return jwt.verify(decrypt(token, secret), secret); + } catch { + return null; + } +} + +export async function parseAuthToken(req: Request, secret: string) { + try { + const token = req.headers.get('authorization')?.split(' ')?.[1]; + + return parseSecureToken(token as string, secret); + } catch { + return null; + } +} diff --git a/src/lib/request.ts b/src/lib/request.ts index c71684b9..2278d838 100644 --- a/src/lib/request.ts +++ b/src/lib/request.ts @@ -1,9 +1,9 @@ import { ZodObject } from 'zod'; import { FILTER_COLUMNS } from 'lib/constants'; import { badRequest, unauthorized } from 'lib/response'; -import { getAllowedUnits, getMinimumUnit } from './date'; -import { getWebsiteDateRange } from '../queries'; +import { getAllowedUnits, getMinimumUnit } from 'lib/date'; import { checkAuth } from 'lib/auth'; +import { getWebsiteDateRange } from 'queries'; export async function getJsonBody(request: Request) { try { diff --git a/src/lib/storage.ts b/src/lib/storage.ts new file mode 100644 index 00000000..f08a7f7a --- /dev/null +++ b/src/lib/storage.ts @@ -0,0 +1,21 @@ +export function setItem(key: string, data: any, session?: boolean): void { + if (typeof window !== 'undefined' && data) { + return (session ? sessionStorage : localStorage).setItem(key, JSON.stringify(data)); + } +} + +export function getItem(key: string, session?: boolean): any { + if (typeof window !== 'undefined') { + const value = (session ? sessionStorage : localStorage).getItem(key); + + if (value !== 'undefined' && value !== null) { + return JSON.parse(value); + } + } +} + +export function removeItem(key: string, session?: boolean): void { + if (typeof window !== 'undefined') { + return (session ? sessionStorage : localStorage).removeItem(key); + } +} diff --git a/src/lib/url.ts b/src/lib/url.ts new file mode 100644 index 00000000..a039d7d8 --- /dev/null +++ b/src/lib/url.ts @@ -0,0 +1,40 @@ +export function getQueryString(params: object = {}): string { + const searchParams = new URLSearchParams(); + + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined) { + searchParams.append(key, value); + } + }); + + return searchParams.toString(); +} + +export function buildUrl(url: string, params: object = {}): string { + const queryString = getQueryString(params); + return `${url}${queryString && '?' + queryString}`; +} + +export function safeDecodeURI(s: string | undefined | null): string | undefined | null { + if (s === undefined || s === null) { + return s; + } + + try { + return decodeURI(s); + } catch (e) { + return s; + } +} + +export function safeDecodeURIComponent(s: string | undefined | null): string | undefined | null { + if (s === undefined || s === null) { + return s; + } + + try { + return decodeURIComponent(s); + } catch (e) { + return s; + } +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 00000000..2b0d9ff7 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,46 @@ +export function hook( + _this: { [x: string]: any }, + method: string | number, + callback: (arg0: any) => void, +) { + const orig = _this[method]; + + return (...args: any) => { + callback.apply(_this, args); + + return orig.apply(_this, args); + }; +} + +export function sleep(ms: number | undefined) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +export function shuffleArray(a) { + const arr = a.slice(); + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + const temp = arr[i]; + arr[i] = arr[j]; + arr[j] = temp; + } + return arr; +} + +export function chunkArray(arr: any[], size: number) { + const chunks: any[] = []; + + let index = 0; + while (index < arr.length) { + chunks.push(arr.slice(index, size + index)); + index += size; + } + + return chunks; +} + +export function ensureArray(arr?: any) { + if (arr === undefined || arr === null) return []; + if (Array.isArray(arr)) return arr; + return [arr]; +} diff --git a/src/queries/analytics/reports/getUTM.ts b/src/queries/analytics/reports/getUTM.ts index 4e1af9f0..0a7ec5f1 100644 --- a/src/queries/analytics/reports/getUTM.ts +++ b/src/queries/analytics/reports/getUTM.ts @@ -1,7 +1,6 @@ import clickhouse from 'lib/clickhouse'; import { CLICKHOUSE, PRISMA, runQuery } from 'lib/db'; import prisma from 'lib/prisma'; -import { safeDecodeURIComponent } from 'next-basics'; export async function getUTM( ...args: [ @@ -84,7 +83,7 @@ function parseParameters(data: any[]) { for (const [key, value] of searchParams) { if (key.match(/^utm_(\w+)$/)) { - const name = safeDecodeURIComponent(value); + const name = value; if (!obj[key]) { obj[key] = { [name]: Number(num) }; } else if (!obj[key][name]) { diff --git a/src/queries/prisma/user.ts b/src/queries/prisma/user.ts index 0c8e3520..581b5e7a 100644 --- a/src/queries/prisma/user.ts +++ b/src/queries/prisma/user.ts @@ -2,7 +2,7 @@ import { Prisma } from '@prisma/client'; import { ROLES } from 'lib/constants'; import prisma from 'lib/prisma'; import { PageResult, Role, User, PageParams } from 'lib/types'; -import { getRandomChars } from 'next-basics'; +import { getRandomChars } from 'lib/crypto'; import UserFindManyArgs = Prisma.UserFindManyArgs; export interface GetUserOptions { diff --git a/src/store/app.ts b/src/store/app.ts index 4d547d4e..626c2f31 100644 --- a/src/store/app.ts +++ b/src/store/app.ts @@ -8,7 +8,7 @@ import { THEME_CONFIG, TIMEZONE_CONFIG, } from 'lib/constants'; -import { getItem } from 'next-basics'; +import { getItem } from 'lib/storage'; import { getTimezone } from 'lib/date'; function getDefaultTheme() { diff --git a/src/store/dashboard.ts b/src/store/dashboard.ts index 0cfc78b9..ecae42f6 100644 --- a/src/store/dashboard.ts +++ b/src/store/dashboard.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; import { DASHBOARD_CONFIG, DEFAULT_WEBSITE_LIMIT } from 'lib/constants'; -import { getItem, setItem } from 'next-basics'; +import { getItem, setItem } from 'lib/storage'; export const initialState = { showCharts: true, diff --git a/src/store/version.ts b/src/store/version.ts index 3b5afaac..8cabaf73 100644 --- a/src/store/version.ts +++ b/src/store/version.ts @@ -2,7 +2,7 @@ import { create } from 'zustand'; import { produce } from 'immer'; import semver from 'semver'; import { CURRENT_VERSION, VERSION_CHECK, UPDATES_URL } from 'lib/constants'; -import { getItem } from 'next-basics'; +import { getItem } from 'lib/storage'; const initialState = { current: CURRENT_VERSION, diff --git a/yarn.lock b/yarn.lock index 1aab5657..225298a9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7411,7 +7411,7 @@ jsonify@^0.0.1: resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.1.tgz#2aa3111dae3d34a0f151c63f3a45d995d9420978" integrity sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg== -jsonwebtoken@^9.0.0: +jsonwebtoken@^9.0.2: version "9.0.2" resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz#65ff91f4abef1784697d40952bb1998c504caaf3" integrity sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ== @@ -8054,15 +8054,6 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== -next-basics@^0.39.0: - version "0.39.0" - resolved "https://registry.yarnpkg.com/next-basics/-/next-basics-0.39.0.tgz#1ec448a1c12966a82067445bfb9319b7e883dd6a" - integrity sha512-5HWf3u7jgx5n4auIkArFP5+EVdyz7kSvxs86o2V4y8/t3J4scdIHgI8BBE6UhzB17WMbMgVql44IfcJH1CQc/w== - dependencies: - bcryptjs "^2.4.3" - jsonwebtoken "^9.0.0" - pure-rand "^6.0.2" - next@15.0.4: version "15.0.4" resolved "https://registry.yarnpkg.com/next/-/next-15.0.4.tgz#7ddad7299204f16c132d7e524cf903f1a513588e" @@ -9179,11 +9170,16 @@ punycode@^2.1.0: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== -pure-rand@^6.0.0, pure-rand@^6.0.2: +pure-rand@^6.0.0: version "6.0.4" resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-6.0.4.tgz#50b737f6a925468679bff00ad20eade53f37d5c7" integrity sha512-LA0Y9kxMYv47GIPJy6MI84fqTd2HmYZI83W/kM/SkKfDlajnZYfmXFTxkbY+xSBPkLJxltMa9hIkmdc29eguMA== +pure-rand@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-6.1.0.tgz#d173cf23258231976ccbdb05247c9787957604f2" + integrity sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA== + qs@6.13.0: version "6.13.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.13.0.tgz#6ca3bd58439f7e245655798997787b0d88a51906"