mirror of
https://github.com/umami-software/umami.git
synced 2026-02-15 18:15:35 +01:00
Merge branch 'api' into dev
This commit is contained in:
commit
31266cb1ac
160 changed files with 3235 additions and 4290 deletions
1
next-env.d.ts
vendored
1
next-env.d.ts
vendored
|
|
@ -1,6 +1,5 @@
|
||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
/// <reference types="next/navigation-types/compat/navigation" />
|
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// 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/api-reference/config/typescript for more information.
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ const basePath = process.env.BASE_PATH;
|
||||||
const collectApiEndpoint = process.env.COLLECT_API_ENDPOINT;
|
const collectApiEndpoint = process.env.COLLECT_API_ENDPOINT;
|
||||||
const cloudMode = process.env.CLOUD_MODE;
|
const cloudMode = process.env.CLOUD_MODE;
|
||||||
const cloudUrl = process.env.CLOUD_URL;
|
const cloudUrl = process.env.CLOUD_URL;
|
||||||
|
const corsMaxAge = process.env.CORS_MAX_AGE;
|
||||||
const defaultLocale = process.env.DEFAULT_LOCALE;
|
const defaultLocale = process.env.DEFAULT_LOCALE;
|
||||||
const disableLogin = process.env.DISABLE_LOGIN;
|
const disableLogin = process.env.DISABLE_LOGIN;
|
||||||
const disableUI = process.env.DISABLE_UI;
|
const disableUI = process.env.DISABLE_UI;
|
||||||
|
|
@ -59,6 +60,15 @@ const trackerHeaders = [
|
||||||
];
|
];
|
||||||
|
|
||||||
const headers = [
|
const headers = [
|
||||||
|
{
|
||||||
|
source: '/api/:path*',
|
||||||
|
headers: [
|
||||||
|
{ key: 'Access-Control-Allow-Origin', value: '*' },
|
||||||
|
{ key: 'Access-Control-Allow-Headers', value: '*' },
|
||||||
|
{ key: 'Access-Control-Allow-Methods', value: 'GET, DELETE, POST, PUT' },
|
||||||
|
{ key: 'Access-Control-Max-Age', value: corsMaxAge || '86400' },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
source: '/:path*',
|
source: '/:path*',
|
||||||
headers: defaultHeaders,
|
headers: defaultHeaders,
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
"url": "https://github.com/umami-software/umami.git"
|
"url": "https://github.com/umami-software/umami.git"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000 --turbo",
|
||||||
"build": "npm-run-all check-env build-db check-db build-tracker build-geo build-app",
|
"build": "npm-run-all check-env build-db check-db build-tracker build-geo build-app",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"build-docker": "npm-run-all build-db build-tracker build-geo build-app",
|
"build-docker": "npm-run-all build-db build-tracker build-geo build-app",
|
||||||
|
|
@ -106,7 +106,7 @@
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
"prisma": "6.1.0",
|
"prisma": "6.1.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-basics": "^0.125.0",
|
"react-basics": "^0.126.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-error-boundary": "^4.0.4",
|
"react-error-boundary": "^4.0.4",
|
||||||
"react-intl": "^6.5.5",
|
"react-intl": "^6.5.5",
|
||||||
|
|
@ -118,7 +118,7 @@
|
||||||
"serialize-error": "^12.0.0",
|
"serialize-error": "^12.0.0",
|
||||||
"thenby": "^1.3.4",
|
"thenby": "^1.3.4",
|
||||||
"uuid": "^9.0.0",
|
"uuid": "^9.0.0",
|
||||||
"yup": "^0.32.11",
|
"zod": "^3.24.1",
|
||||||
"zustand": "^4.5.5"
|
"zustand": "^4.5.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,10 @@ export function App({ children }) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (config.uiDisabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,7 @@ import NavBar from './NavBar';
|
||||||
import Page from 'components/layout/Page';
|
import Page from 'components/layout/Page';
|
||||||
import styles from './layout.module.css';
|
import styles from './layout.module.css';
|
||||||
|
|
||||||
export default function ({ children }) {
|
export default async function ({ children }) {
|
||||||
if (process.env.DISABLE_UI) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<App>
|
<App>
|
||||||
<main className={styles.layout}>
|
<main className={styles.layout}>
|
||||||
|
|
|
||||||
|
|
@ -226,7 +226,7 @@ const ResultsMenu = ({ values, type, isLoading, onSelect }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu className={styles.menu} variant="popup" onSelect={onSelect}>
|
<Menu className={styles.menu} variant="popup" onSelect={onSelect}>
|
||||||
{values?.map((value: any) => {
|
{values?.map(({ value }) => {
|
||||||
return <Item key={value}>{formatValue(value, type)}</Item>;
|
return <Item key={value}>{formatValue(value, type)}</Item>;
|
||||||
})}
|
})}
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,11 @@ export function ShareUrl({ hostUrl, onSave }: { hostUrl?: string; onSave?: () =>
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCheck = (checked: boolean) => {
|
const handleCheck = (checked: boolean) => {
|
||||||
const data = { shareId: checked ? generateId() : null };
|
const data = {
|
||||||
|
name: website.name,
|
||||||
|
domain: website.domain,
|
||||||
|
shareId: checked ? generateId() : null,
|
||||||
|
};
|
||||||
mutate(data, {
|
mutate(data, {
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
touch(`website:${website.id}`);
|
touch(`website:${website.id}`);
|
||||||
|
|
@ -47,7 +51,7 @@ export function ShareUrl({ hostUrl, onSave }: { hostUrl?: string; onSave?: () =>
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
mutate(
|
mutate(
|
||||||
{ shareId: id },
|
{ name: website.name, domain: website.domain, shareId: id },
|
||||||
{
|
{
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
touch(`website:${website.id}`);
|
touch(`website:${website.id}`);
|
||||||
|
|
|
||||||
|
|
@ -54,10 +54,10 @@ export function SessionsWeekly({ websiteId }: { websiteId: string }) {
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
{format(getDayOfWeekAsDate(index), 'EEE', { locale: dateLocale })}
|
{format(getDayOfWeekAsDate(index), 'EEE', { locale: dateLocale })}
|
||||||
</div>
|
</div>
|
||||||
{day?.map((hour: number) => {
|
{day?.map((hour: number, n) => {
|
||||||
const pct = hour / max;
|
const pct = hour / max;
|
||||||
return (
|
return (
|
||||||
<div key={hour} className={classNames(styles.cell)}>
|
<div key={n} className={classNames(styles.cell)}>
|
||||||
{hour > 0 && (
|
{hour > 0 && (
|
||||||
<TooltipPopup
|
<TooltipPopup
|
||||||
label={`${formatMessage(labels.visitors)}: ${hour}`}
|
label={`${formatMessage(labels.visitors)}: ${hour}`}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { Loading, Icon, StatusLight } from 'react-basics';
|
||||||
import Icons from 'components/icons';
|
import Icons from 'components/icons';
|
||||||
import { useSessionActivity, useTimezone } from 'components/hooks';
|
import { useSessionActivity, useTimezone } from 'components/hooks';
|
||||||
import styles from './SessionActivity.module.css';
|
import styles from './SessionActivity.module.css';
|
||||||
|
import { Fragment } from 'react';
|
||||||
|
|
||||||
export function SessionActivity({
|
export function SessionActivity({
|
||||||
websiteId,
|
websiteId,
|
||||||
|
|
@ -31,7 +32,7 @@ export function SessionActivity({
|
||||||
lastDay = createdAt;
|
lastDay = createdAt;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Fragment key={eventId}>
|
||||||
{showHeader && (
|
{showHeader && (
|
||||||
<div className={styles.header}>{formatTimezoneDate(createdAt, 'EEEE, PPP')}</div>
|
<div className={styles.header}>{formatTimezoneDate(createdAt, 'EEEE, PPP')}</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -44,7 +45,7 @@ export function SessionActivity({
|
||||||
<Icon>{eventName ? <Icons.Bolt /> : <Icons.Eye />}</Icon>
|
<Icon>{eventName ? <Icons.Bolt /> : <Icons.Eye />}</Icon>
|
||||||
<div>{eventName || urlPath}</div>
|
<div>{eventName || urlPath}</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
10
src/app/actions/getConfig.ts
Normal file
10
src/app/actions/getConfig.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
'use server';
|
||||||
|
|
||||||
|
export async function getConfig() {
|
||||||
|
return {
|
||||||
|
telemetryDisabled: !!process.env.DISABLE_TELEMETRY,
|
||||||
|
trackerScriptName: process.env.TRACKER_SCRIPT_NAME,
|
||||||
|
uiDisabled: !!process.env.DISABLE_UI,
|
||||||
|
updatesDisabled: !!process.env.DISABLE_UPDATES,
|
||||||
|
};
|
||||||
|
}
|
||||||
39
src/app/api/admin/users/route.ts
Normal file
39
src/app/api/admin/users/route.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { parseRequest } from 'lib/request';
|
||||||
|
import { json, unauthorized } from 'lib/response';
|
||||||
|
import { pagingParams } from 'lib/schema';
|
||||||
|
import { canViewUsers } from 'lib/auth';
|
||||||
|
import { getUsers } from 'queries/prisma/user';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const schema = z.object({
|
||||||
|
...pagingParams,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, query, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await canViewUsers(auth))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = await getUsers(
|
||||||
|
{
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
websiteUser: {
|
||||||
|
where: { deletedAt: null },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
query,
|
||||||
|
);
|
||||||
|
|
||||||
|
return json(users);
|
||||||
|
}
|
||||||
87
src/app/api/admin/websites/route.ts
Normal file
87
src/app/api/admin/websites/route.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { parseRequest } from 'lib/request';
|
||||||
|
import { json, unauthorized } from 'lib/response';
|
||||||
|
import { pagingParams } from 'lib/schema';
|
||||||
|
import { canViewAllWebsites } from 'lib/auth';
|
||||||
|
import { getWebsites } from 'queries/prisma/website';
|
||||||
|
import { ROLES } from 'lib/constants';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const schema = z.object({
|
||||||
|
...pagingParams,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, query, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await canViewAllWebsites(auth))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { userId, includeOwnedTeams, includeAllTeams } = query;
|
||||||
|
|
||||||
|
const websites = await getWebsites(
|
||||||
|
{
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
...(userId && [{ userId }]),
|
||||||
|
...(userId && includeOwnedTeams
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
team: {
|
||||||
|
deletedAt: null,
|
||||||
|
teamUser: {
|
||||||
|
some: {
|
||||||
|
role: ROLES.teamOwner,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(userId && includeAllTeams
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
team: {
|
||||||
|
deletedAt: null,
|
||||||
|
teamUser: {
|
||||||
|
some: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
username: true,
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
team: {
|
||||||
|
where: {
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
teamUser: {
|
||||||
|
where: {
|
||||||
|
role: ROLES.teamOwner,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
query,
|
||||||
|
);
|
||||||
|
|
||||||
|
return json(websites);
|
||||||
|
}
|
||||||
44
src/app/api/auth/login/route.ts
Normal file
44
src/app/api/auth/login/route.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { checkPassword, createSecureToken } from 'next-basics';
|
||||||
|
import { redisEnabled } from '@umami/redis-client';
|
||||||
|
import { getUserByUsername } from 'queries';
|
||||||
|
import { json, unauthorized } from 'lib/response';
|
||||||
|
import { parseRequest } from 'lib/request';
|
||||||
|
import { saveAuth } from 'lib/auth';
|
||||||
|
import { secret } from 'lib/crypto';
|
||||||
|
import { ROLES } from 'lib/constants';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const schema = z.object({
|
||||||
|
username: z.string(),
|
||||||
|
password: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { body, error } = await parseRequest(request, schema, { skipAuth: true });
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { username, password } = body;
|
||||||
|
|
||||||
|
const user = await getUserByUsername(username, { includePassword: true });
|
||||||
|
|
||||||
|
if (!user || !checkPassword(password, user.password)) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (redisEnabled) {
|
||||||
|
const token = await saveAuth({ userId: user.id });
|
||||||
|
|
||||||
|
return json({ token, user });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = createSecureToken({ userId: user.id }, secret());
|
||||||
|
const { id, role, createdAt } = user;
|
||||||
|
|
||||||
|
return json({
|
||||||
|
token,
|
||||||
|
user: { id, username, role, createdAt, isAdmin: role === ROLES.admin },
|
||||||
|
});
|
||||||
|
}
|
||||||
14
src/app/api/auth/logout/route.ts
Normal file
14
src/app/api/auth/logout/route.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { getClient, redisEnabled } from '@umami/redis-client';
|
||||||
|
import { ok } from 'lib/response';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
if (redisEnabled) {
|
||||||
|
const redis = getClient();
|
||||||
|
|
||||||
|
const token = request.headers.get('authorization')?.split(' ')?.[1];
|
||||||
|
|
||||||
|
await redis.del(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok();
|
||||||
|
}
|
||||||
18
src/app/api/auth/sso/route.ts
Normal file
18
src/app/api/auth/sso/route.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { redisEnabled } from '@umami/redis-client';
|
||||||
|
import { json } from 'lib/response';
|
||||||
|
import { parseRequest } from 'lib/request';
|
||||||
|
import { saveAuth } from 'lib/auth';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const { auth, error } = await parseRequest(request);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (redisEnabled) {
|
||||||
|
const token = await saveAuth({ userId: auth.user.id }, 86400);
|
||||||
|
|
||||||
|
return json({ user: auth.user, token });
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/app/api/auth/verify/route.ts
Normal file
12
src/app/api/auth/verify/route.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { parseRequest } from 'lib/request';
|
||||||
|
import { json } from 'lib/response';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const { auth, error } = await parseRequest(request);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
return json(auth.user);
|
||||||
|
}
|
||||||
3
src/app/api/heartbeat/route.ts
Normal file
3
src/app/api/heartbeat/route.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export async function GET() {
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
}
|
||||||
33
src/app/api/me/password/route.ts
Normal file
33
src/app/api/me/password/route.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
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({
|
||||||
|
currentPassword: z.string(),
|
||||||
|
newPassword: z.string().min(8),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, body, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = auth.user.id;
|
||||||
|
const { currentPassword, newPassword } = body;
|
||||||
|
|
||||||
|
const user = await getUser(userId, { includePassword: true });
|
||||||
|
|
||||||
|
if (!checkPassword(currentPassword, user.password)) {
|
||||||
|
return badRequest('Current password is incorrect');
|
||||||
|
}
|
||||||
|
|
||||||
|
const password = hashPassword(newPassword);
|
||||||
|
|
||||||
|
const updated = await updateUser(userId, { password });
|
||||||
|
|
||||||
|
return json(updated);
|
||||||
|
}
|
||||||
12
src/app/api/me/route.ts
Normal file
12
src/app/api/me/route.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { parseRequest } from 'lib/request';
|
||||||
|
import { json } from 'lib/response';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const { auth, error } = await parseRequest(request);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
return json(auth);
|
||||||
|
}
|
||||||
21
src/app/api/me/teams/route.ts
Normal file
21
src/app/api/me/teams/route.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { pagingParams } from 'lib/schema';
|
||||||
|
import { getUserTeams } from 'queries';
|
||||||
|
import { json } from 'lib/response';
|
||||||
|
import { parseRequest } from 'lib/request';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const schema = z.object({
|
||||||
|
...pagingParams,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, query, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const teams = await getUserTeams(auth.user.id, query);
|
||||||
|
|
||||||
|
return json(teams);
|
||||||
|
}
|
||||||
21
src/app/api/me/websites/route.ts
Normal file
21
src/app/api/me/websites/route.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { pagingParams } from 'lib/schema';
|
||||||
|
import { getUserWebsites } from 'queries';
|
||||||
|
import { json } from 'lib/response';
|
||||||
|
import { parseRequest } from 'lib/request';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const schema = z.object({
|
||||||
|
...pagingParams,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, query, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const websites = await getUserWebsites(auth.user.id, query);
|
||||||
|
|
||||||
|
return json(websites);
|
||||||
|
}
|
||||||
30
src/app/api/realtime/[websiteId]/route.ts
Normal file
30
src/app/api/realtime/[websiteId]/route.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { json, unauthorized } from 'lib/response';
|
||||||
|
import { getRealtimeData } from 'queries';
|
||||||
|
import { canViewWebsite } from 'lib/auth';
|
||||||
|
import { startOfMinute, subMinutes } from 'date-fns';
|
||||||
|
import { REALTIME_RANGE } from 'lib/constants';
|
||||||
|
import { parseRequest } from 'lib/request';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ websiteId: string }> },
|
||||||
|
) {
|
||||||
|
const { auth, query, error } = await parseRequest(request);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { websiteId } = await params;
|
||||||
|
const { timezone } = query;
|
||||||
|
|
||||||
|
if (!(await canViewWebsite(auth, websiteId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDate = subMinutes(startOfMinute(new Date()), REALTIME_RANGE);
|
||||||
|
|
||||||
|
const data = await getRealtimeData(websiteId, { startDate, timezone });
|
||||||
|
|
||||||
|
return json(data);
|
||||||
|
}
|
||||||
91
src/app/api/reports/[reportId]/route.ts
Normal file
91
src/app/api/reports/[reportId]/route.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { parseRequest } from 'lib/request';
|
||||||
|
import { deleteReport, getReport, updateReport } from 'queries';
|
||||||
|
import { canDeleteReport, canUpdateReport, canViewReport } from 'lib/auth';
|
||||||
|
import { unauthorized, json, notFound, ok } from 'lib/response';
|
||||||
|
import { reportTypeParam } from 'lib/schema';
|
||||||
|
|
||||||
|
export async function GET(request: Request, { params }: { params: Promise<{ reportId: string }> }) {
|
||||||
|
const { auth, error } = await parseRequest(request);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { reportId } = await params;
|
||||||
|
|
||||||
|
const report = await getReport(reportId);
|
||||||
|
|
||||||
|
if (!(await canViewReport(auth, report))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
report.parameters = JSON.parse(report.parameters);
|
||||||
|
|
||||||
|
return json(report);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ reportId: string }> },
|
||||||
|
) {
|
||||||
|
const schema = z.object({
|
||||||
|
websiteId: z.string().uuid(),
|
||||||
|
type: reportTypeParam,
|
||||||
|
name: z.string().max(200),
|
||||||
|
description: z.string().max(500),
|
||||||
|
parameters: z.object({}).passthrough(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, body, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { reportId } = await params;
|
||||||
|
const { websiteId, type, name, description, parameters } = body;
|
||||||
|
|
||||||
|
const report = await getReport(reportId);
|
||||||
|
|
||||||
|
if (!report) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await canUpdateReport(auth, report))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await updateReport(reportId, {
|
||||||
|
websiteId,
|
||||||
|
userId: auth.user.id,
|
||||||
|
type,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
parameters: JSON.stringify(parameters),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
return json(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ reportId: string }> },
|
||||||
|
) {
|
||||||
|
const { auth, error } = await parseRequest(request);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { reportId } = await params;
|
||||||
|
const report = await getReport(reportId);
|
||||||
|
|
||||||
|
if (!(await canDeleteReport(auth, report))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteReport(reportId);
|
||||||
|
|
||||||
|
return ok();
|
||||||
|
}
|
||||||
47
src/app/api/reports/funnel/route.ts
Normal file
47
src/app/api/reports/funnel/route.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { canViewWebsite } from 'lib/auth';
|
||||||
|
import { unauthorized, json } from 'lib/response';
|
||||||
|
import { parseRequest } from 'lib/request';
|
||||||
|
import { getFunnel } from 'queries';
|
||||||
|
import { reportParms } from 'lib/schema';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const schema = z.object({
|
||||||
|
...reportParms,
|
||||||
|
window: z.number().positive(),
|
||||||
|
steps: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
type: z.string(),
|
||||||
|
value: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.min(2),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, body, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
websiteId,
|
||||||
|
steps,
|
||||||
|
window,
|
||||||
|
dateRange: { startDate, endDate },
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
if (!(await canViewWebsite(auth, websiteId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await getFunnel(websiteId, {
|
||||||
|
startDate: new Date(startDate),
|
||||||
|
endDate: new Date(endDate),
|
||||||
|
steps,
|
||||||
|
windowMinutes: +window,
|
||||||
|
});
|
||||||
|
|
||||||
|
return json(data);
|
||||||
|
}
|
||||||
57
src/app/api/reports/goals/route.ts
Normal file
57
src/app/api/reports/goals/route.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { canViewWebsite } from 'lib/auth';
|
||||||
|
import { unauthorized, json } from 'lib/response';
|
||||||
|
import { parseRequest } from 'lib/request';
|
||||||
|
import { getGoals } from 'queries/analytics/reports/getGoals';
|
||||||
|
import { reportParms } from 'lib/schema';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const schema = z.object({
|
||||||
|
...reportParms,
|
||||||
|
goals: z
|
||||||
|
.array(
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
type: z.string().regex(/url|event|event-data/),
|
||||||
|
value: z.string(),
|
||||||
|
goal: z.coerce.number(),
|
||||||
|
operator: z
|
||||||
|
.string()
|
||||||
|
.regex(/count|sum|average/)
|
||||||
|
.optional(),
|
||||||
|
property: z.string().optional(),
|
||||||
|
})
|
||||||
|
.refine(data => {
|
||||||
|
if (data['type'] === 'event-data') {
|
||||||
|
return data['operator'] && data['property'];
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, body, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
websiteId,
|
||||||
|
dateRange: { startDate, endDate },
|
||||||
|
goals,
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
if (!(await canViewWebsite(auth, websiteId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await getGoals(websiteId, {
|
||||||
|
startDate: new Date(startDate),
|
||||||
|
endDate: new Date(endDate),
|
||||||
|
goals,
|
||||||
|
});
|
||||||
|
|
||||||
|
return json(data);
|
||||||
|
}
|
||||||
62
src/app/api/reports/insights/route.ts
Normal file
62
src/app/api/reports/insights/route.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { canViewWebsite } from 'lib/auth';
|
||||||
|
import { unauthorized, json } from 'lib/response';
|
||||||
|
import { parseRequest } from 'lib/request';
|
||||||
|
import { getInsights } from 'queries';
|
||||||
|
import { reportParms } from 'lib/schema';
|
||||||
|
|
||||||
|
function convertFilters(filters: any[]) {
|
||||||
|
return filters.reduce((obj, filter) => {
|
||||||
|
obj[filter.name] = filter;
|
||||||
|
|
||||||
|
return obj;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const schema = z.object({
|
||||||
|
...reportParms,
|
||||||
|
fields: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
name: z.string(),
|
||||||
|
type: z.string(),
|
||||||
|
label: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.min(1),
|
||||||
|
filters: z.array(
|
||||||
|
z.object({
|
||||||
|
name: z.string(),
|
||||||
|
type: z.string(),
|
||||||
|
operator: z.string(),
|
||||||
|
value: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, body, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
websiteId,
|
||||||
|
dateRange: { startDate, endDate },
|
||||||
|
fields,
|
||||||
|
filters,
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
if (!(await canViewWebsite(auth, websiteId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await getInsights(websiteId, fields, {
|
||||||
|
...convertFilters(filters),
|
||||||
|
startDate: new Date(startDate),
|
||||||
|
endDate: new Date(endDate),
|
||||||
|
});
|
||||||
|
|
||||||
|
return json(data);
|
||||||
|
}
|
||||||
43
src/app/api/reports/journey/route.ts
Normal file
43
src/app/api/reports/journey/route.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { canViewWebsite } from 'lib/auth';
|
||||||
|
import { unauthorized, json } from 'lib/response';
|
||||||
|
import { parseRequest } from 'lib/request';
|
||||||
|
import { getJourney } from 'queries';
|
||||||
|
import { reportParms } from 'lib/schema';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const schema = z.object({
|
||||||
|
...reportParms,
|
||||||
|
steps: z.number().min(3).max(7),
|
||||||
|
startStep: z.string(),
|
||||||
|
endStep: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, body, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
websiteId,
|
||||||
|
dateRange: { startDate, endDate },
|
||||||
|
steps,
|
||||||
|
startStep,
|
||||||
|
endStep,
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
if (!(await canViewWebsite(auth, websiteId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await getJourney(websiteId, {
|
||||||
|
startDate: new Date(startDate),
|
||||||
|
endDate: new Date(endDate),
|
||||||
|
steps,
|
||||||
|
startStep,
|
||||||
|
endStep,
|
||||||
|
});
|
||||||
|
|
||||||
|
return json(data);
|
||||||
|
}
|
||||||
37
src/app/api/reports/retention/route.ts
Normal file
37
src/app/api/reports/retention/route.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { canViewWebsite } from 'lib/auth';
|
||||||
|
import { unauthorized, json } from 'lib/response';
|
||||||
|
import { parseRequest } from 'lib/request';
|
||||||
|
import { getRetention } from 'queries';
|
||||||
|
import { reportParms, timezoneParam } from 'lib/schema';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const schema = z.object({
|
||||||
|
...reportParms,
|
||||||
|
timezone: timezoneParam,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, body, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
websiteId,
|
||||||
|
dateRange: { startDate, endDate },
|
||||||
|
timezone,
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
if (!(await canViewWebsite(auth, websiteId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await getRetention(websiteId, {
|
||||||
|
startDate: new Date(startDate),
|
||||||
|
endDate: new Date(endDate),
|
||||||
|
timezone,
|
||||||
|
});
|
||||||
|
|
||||||
|
return json(data);
|
||||||
|
}
|
||||||
62
src/app/api/reports/revenue/route.ts
Normal file
62
src/app/api/reports/revenue/route.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { canViewWebsite } from 'lib/auth';
|
||||||
|
import { unauthorized, json } from 'lib/response';
|
||||||
|
import { parseRequest } from 'lib/request';
|
||||||
|
import { reportParms, timezoneParam } from 'lib/schema';
|
||||||
|
import { getRevenue } from 'queries/analytics/reports/getRevenue';
|
||||||
|
import { getRevenueValues } from 'queries/analytics/reports/getRevenueValues';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const { auth, query, error } = await parseRequest(request);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { websiteId, startDate, endDate } = query;
|
||||||
|
|
||||||
|
if (!(await canViewWebsite(auth, websiteId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await getRevenueValues(websiteId, {
|
||||||
|
startDate: new Date(startDate),
|
||||||
|
endDate: new Date(endDate),
|
||||||
|
});
|
||||||
|
|
||||||
|
return json(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const schema = z.object({
|
||||||
|
...reportParms,
|
||||||
|
timezone: timezoneParam,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, body, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
websiteId,
|
||||||
|
currency,
|
||||||
|
timezone,
|
||||||
|
dateRange: { startDate, endDate, unit },
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
if (!(await canViewWebsite(auth, websiteId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await getRevenue(websiteId, {
|
||||||
|
startDate: new Date(startDate),
|
||||||
|
endDate: new Date(endDate),
|
||||||
|
unit,
|
||||||
|
timezone,
|
||||||
|
currency,
|
||||||
|
});
|
||||||
|
|
||||||
|
return json(data);
|
||||||
|
}
|
||||||
110
src/app/api/reports/route.ts
Normal file
110
src/app/api/reports/route.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { uuid } from 'lib/crypto';
|
||||||
|
import { pagingParams, reportTypeParam } from 'lib/schema';
|
||||||
|
import { parseRequest } from 'lib/request';
|
||||||
|
import { canViewTeam, canViewWebsite, canUpdateWebsite } from 'lib/auth';
|
||||||
|
import { unauthorized, json } from 'lib/response';
|
||||||
|
import { getReports, createReport } from 'queries';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const schema = z.object({
|
||||||
|
websiteId: z.string().uuid().optional(),
|
||||||
|
teamId: z.string().uuid().optional(),
|
||||||
|
...pagingParams,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, query, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { page, search, pageSize, websiteId, teamId } = query;
|
||||||
|
const userId = auth.user.id;
|
||||||
|
const filters = {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
search,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
(websiteId && !(await canViewWebsite(auth, websiteId))) ||
|
||||||
|
(teamId && !(await canViewTeam(auth, teamId)))
|
||||||
|
) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await getReports(
|
||||||
|
{
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
...(websiteId ? [{ websiteId }] : []),
|
||||||
|
...(teamId
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
website: {
|
||||||
|
deletedAt: null,
|
||||||
|
teamId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(userId && !websiteId && !teamId
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
website: {
|
||||||
|
deletedAt: null,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
website: {
|
||||||
|
select: {
|
||||||
|
domain: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
filters,
|
||||||
|
);
|
||||||
|
|
||||||
|
return json(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const schema = z.object({
|
||||||
|
websiteId: z.string().uuid(),
|
||||||
|
name: z.string().max(200),
|
||||||
|
type: reportTypeParam,
|
||||||
|
description: z.string().max(500),
|
||||||
|
parameters: z.object({}).passthrough(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, body, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { websiteId, type, name, description, parameters } = body;
|
||||||
|
|
||||||
|
if (!(await canUpdateWebsite(auth, websiteId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await createReport({
|
||||||
|
id: uuid(),
|
||||||
|
userId: auth.user.id,
|
||||||
|
websiteId,
|
||||||
|
type,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
parameters: JSON.stringify(parameters),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
return json(result);
|
||||||
|
}
|
||||||
35
src/app/api/reports/utm/route.ts
Normal file
35
src/app/api/reports/utm/route.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { canViewWebsite } from 'lib/auth';
|
||||||
|
import { unauthorized, json } from 'lib/response';
|
||||||
|
import { parseRequest } from 'lib/request';
|
||||||
|
import { getUTM } from 'queries';
|
||||||
|
import { reportParms } from 'lib/schema';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const schema = z.object({
|
||||||
|
...reportParms,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, body, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
websiteId,
|
||||||
|
dateRange: { startDate, endDate, timezone },
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
if (!(await canViewWebsite(auth, websiteId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await getUTM(websiteId, {
|
||||||
|
startDate: new Date(startDate),
|
||||||
|
endDate: new Date(endDate),
|
||||||
|
timezone,
|
||||||
|
});
|
||||||
|
|
||||||
|
return json(data);
|
||||||
|
}
|
||||||
192
src/app/api/send/route.ts
Normal file
192
src/app/api/send/route.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { isbot } from 'isbot';
|
||||||
|
import { createToken, parseToken, safeDecodeURI } from 'next-basics';
|
||||||
|
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';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
// Bot check
|
||||||
|
if (!process.env.DISABLE_BOT_CHECK && isbot(request.headers.get('user-agent'))) {
|
||||||
|
return json({ beep: 'boop' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
type: z.enum(['event', 'identity']),
|
||||||
|
payload: z.object({
|
||||||
|
website: z.string().uuid(),
|
||||||
|
data: z.object({}).passthrough().optional(),
|
||||||
|
hostname: z.string().max(100).optional(),
|
||||||
|
language: z.string().max(35).optional(),
|
||||||
|
referrer: z.string().optional(),
|
||||||
|
screen: z.string().max(11).optional(),
|
||||||
|
title: z.string().optional(),
|
||||||
|
url: z.string().optional(),
|
||||||
|
name: z.string().max(50).optional(),
|
||||||
|
tag: z.string().max(50).optional(),
|
||||||
|
ip: z.string().ip().optional(),
|
||||||
|
userAgent: z.string().optional(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { body, error } = await parseRequest(request, schema, { skipAuth: true });
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { type, payload } = body;
|
||||||
|
|
||||||
|
const {
|
||||||
|
website: websiteId,
|
||||||
|
hostname,
|
||||||
|
screen,
|
||||||
|
language,
|
||||||
|
url,
|
||||||
|
referrer,
|
||||||
|
name,
|
||||||
|
data,
|
||||||
|
title,
|
||||||
|
tag,
|
||||||
|
} = payload;
|
||||||
|
|
||||||
|
// Cache check
|
||||||
|
let cache: { websiteId: string; sessionId: string; visitId: string; iat: number } | null = null;
|
||||||
|
const cacheHeader = request.headers.get('x-umami-cache');
|
||||||
|
|
||||||
|
if (cacheHeader) {
|
||||||
|
const result = await parseToken(cacheHeader, secret());
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
cache = result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find website
|
||||||
|
if (!cache?.websiteId) {
|
||||||
|
const website = await fetchWebsite(websiteId);
|
||||||
|
|
||||||
|
if (!website) {
|
||||||
|
return badRequest('Website not found.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client info
|
||||||
|
const { ip, userAgent, device, browser, os, country, subdivision1, subdivision2, city } =
|
||||||
|
await getClientInfo(request, payload);
|
||||||
|
|
||||||
|
// IP block
|
||||||
|
if (hasBlockedIp(ip)) {
|
||||||
|
return forbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = uuid(websiteId, hostname, ip, userAgent);
|
||||||
|
|
||||||
|
// Find session
|
||||||
|
if (!cache?.sessionId) {
|
||||||
|
const session = await fetchSession(websiteId, sessionId);
|
||||||
|
|
||||||
|
// Create a session if not found
|
||||||
|
if (!session && !clickhouse.enabled) {
|
||||||
|
try {
|
||||||
|
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')) {
|
||||||
|
return serverError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visit info
|
||||||
|
const now = Math.floor(new Date().getTime() / 1000);
|
||||||
|
let visitId = cache?.visitId || uuid(sessionId, visitSalt());
|
||||||
|
let iat = cache?.iat || now;
|
||||||
|
|
||||||
|
// Expire visit after 30 minutes
|
||||||
|
if (now - iat > 1800) {
|
||||||
|
visitId = uuid(sessionId, visitSalt());
|
||||||
|
iat = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === COLLECTION_TYPE.event) {
|
||||||
|
// eslint-disable-next-line prefer-const
|
||||||
|
let [urlPath, urlQuery] = safeDecodeURI(url)?.split('?') || [];
|
||||||
|
let [referrerPath, referrerQuery] = safeDecodeURI(referrer)?.split('?') || [];
|
||||||
|
let referrerDomain = '';
|
||||||
|
|
||||||
|
if (!urlPath) {
|
||||||
|
urlPath = '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^[\w-]+:\/\/\w+/.test(referrerPath)) {
|
||||||
|
const refUrl = new URL(referrer);
|
||||||
|
referrerPath = refUrl.pathname;
|
||||||
|
referrerQuery = refUrl.search.substring(1);
|
||||||
|
referrerDomain = refUrl.hostname.replace(/www\./, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.REMOVE_TRAILING_SLASH) {
|
||||||
|
urlPath = urlPath.replace(/(.+)\/$/, '$1');
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveEvent({
|
||||||
|
websiteId,
|
||||||
|
sessionId,
|
||||||
|
visitId,
|
||||||
|
urlPath,
|
||||||
|
urlQuery,
|
||||||
|
referrerPath,
|
||||||
|
referrerQuery,
|
||||||
|
referrerDomain,
|
||||||
|
pageTitle: title,
|
||||||
|
eventName: name,
|
||||||
|
eventData: data,
|
||||||
|
hostname,
|
||||||
|
browser,
|
||||||
|
os,
|
||||||
|
device,
|
||||||
|
screen,
|
||||||
|
language,
|
||||||
|
country,
|
||||||
|
subdivision1,
|
||||||
|
subdivision2,
|
||||||
|
city,
|
||||||
|
tag,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === COLLECTION_TYPE.identify) {
|
||||||
|
if (!data) {
|
||||||
|
return badRequest('Data required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveSessionData({
|
||||||
|
websiteId,
|
||||||
|
sessionId,
|
||||||
|
sessionData: data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = createToken({ websiteId, sessionId, visitId, iat }, secret());
|
||||||
|
|
||||||
|
return json({ cache: token });
|
||||||
|
}
|
||||||
19
src/app/api/share/[shareId]/route.ts
Normal file
19
src/app/api/share/[shareId]/route.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { json, notFound } from 'lib/response';
|
||||||
|
import { getSharedWebsite } from 'queries';
|
||||||
|
import { createToken } from 'next-basics';
|
||||||
|
import { secret } from 'lib/crypto';
|
||||||
|
|
||||||
|
export async function GET(request: Request, { params }: { params: Promise<{ shareId: string }> }) {
|
||||||
|
const { shareId } = await params;
|
||||||
|
|
||||||
|
const website = await getSharedWebsite(shareId);
|
||||||
|
|
||||||
|
if (!website) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = { websiteId: website.id };
|
||||||
|
const token = createToken(data, secret());
|
||||||
|
|
||||||
|
return json({ ...data, token });
|
||||||
|
}
|
||||||
71
src/app/api/teams/[teamId]/route.ts
Normal file
71
src/app/api/teams/[teamId]/route.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { unauthorized, json, notFound, ok } from 'lib/response';
|
||||||
|
import { canDeleteTeam, canUpdateTeam, canViewTeam } from 'lib/auth';
|
||||||
|
import { parseRequest } from 'lib/request';
|
||||||
|
import { deleteTeam, getTeam, updateTeam } from 'queries';
|
||||||
|
|
||||||
|
export async function GET(request: Request, { params }: { params: Promise<{ teamId: string }> }) {
|
||||||
|
const { auth, error } = await parseRequest(request);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { teamId } = await params;
|
||||||
|
|
||||||
|
if (!(await canViewTeam(auth, teamId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const team = await getTeam(teamId, { includeMembers: true });
|
||||||
|
|
||||||
|
if (!team) {
|
||||||
|
return notFound('Team not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return json(team);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request, { params }: { params: Promise<{ teamId: string }> }) {
|
||||||
|
const schema = z.object({
|
||||||
|
name: z.string().max(50),
|
||||||
|
accessCode: z.string().max(50),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, body, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { teamId } = await params;
|
||||||
|
|
||||||
|
if (!(await canUpdateTeam(auth, teamId))) {
|
||||||
|
return unauthorized('You must be the owner of this team.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const team = await updateTeam(teamId, body);
|
||||||
|
|
||||||
|
return json(team);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ teamId: string }> },
|
||||||
|
) {
|
||||||
|
const { auth, error } = await parseRequest(request);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { teamId } = await params;
|
||||||
|
|
||||||
|
if (!(await canDeleteTeam(auth, teamId))) {
|
||||||
|
return unauthorized('You must be the owner of this team.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteTeam(teamId);
|
||||||
|
|
||||||
|
return ok();
|
||||||
|
}
|
||||||
78
src/app/api/teams/[teamId]/users/[userId]/route.ts
Normal file
78
src/app/api/teams/[teamId]/users/[userId]/route.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { unauthorized, json, badRequest, ok } from 'lib/response';
|
||||||
|
import { canDeleteTeam, canUpdateTeam } from 'lib/auth';
|
||||||
|
import { parseRequest } from 'lib/request';
|
||||||
|
import { deleteTeam, getTeamUser, updateTeamUser } from 'queries';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ teamId: string; userId: string }> },
|
||||||
|
) {
|
||||||
|
const { auth, error } = await parseRequest(request);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { teamId, userId } = await params;
|
||||||
|
|
||||||
|
if (!(await canUpdateTeam(auth, teamId))) {
|
||||||
|
return unauthorized('You must be the owner of this team.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamUser = await getTeamUser(teamId, userId);
|
||||||
|
|
||||||
|
return json(teamUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ teamId: string; userId: string }> },
|
||||||
|
) {
|
||||||
|
const schema = z.object({
|
||||||
|
role: z.string().regex(/team-member|team-view-only|team-manager/),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, body, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { teamId, userId } = await params;
|
||||||
|
|
||||||
|
if (!(await canUpdateTeam(auth, teamId))) {
|
||||||
|
return unauthorized('You must be the owner of this team.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamUser = await getTeamUser(teamId, userId);
|
||||||
|
|
||||||
|
if (!teamUser) {
|
||||||
|
return badRequest('The User does not exists on this team.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await updateTeamUser(teamUser.id, body);
|
||||||
|
|
||||||
|
return json(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ teamId: string }> },
|
||||||
|
) {
|
||||||
|
const { auth, error } = await parseRequest(request);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { teamId } = await params;
|
||||||
|
|
||||||
|
if (!(await canDeleteTeam(auth, teamId))) {
|
||||||
|
return unauthorized('You must be the owner of this team.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteTeam(teamId);
|
||||||
|
|
||||||
|
return ok();
|
||||||
|
}
|
||||||
79
src/app/api/teams/[teamId]/users/route.ts
Normal file
79
src/app/api/teams/[teamId]/users/route.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { unauthorized, json, badRequest } from 'lib/response';
|
||||||
|
import { canAddUserToTeam, canUpdateTeam } from 'lib/auth';
|
||||||
|
import { parseRequest } from 'lib/request';
|
||||||
|
import { pagingParams, roleParam } from 'lib/schema';
|
||||||
|
import { createTeamUser, getTeamUser, getTeamUsers } from 'queries';
|
||||||
|
|
||||||
|
export async function GET(request: Request, { params }: { params: Promise<{ teamId: string }> }) {
|
||||||
|
const schema = z.object({
|
||||||
|
...pagingParams,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, query, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { teamId } = await params;
|
||||||
|
|
||||||
|
if (!(await canUpdateTeam(auth, teamId))) {
|
||||||
|
return unauthorized('You must be the owner of this team.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = await getTeamUsers(
|
||||||
|
{
|
||||||
|
where: {
|
||||||
|
teamId,
|
||||||
|
user: {
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
query,
|
||||||
|
);
|
||||||
|
|
||||||
|
return json(users);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ teamId: string; userId: string }> },
|
||||||
|
) {
|
||||||
|
const schema = z.object({
|
||||||
|
role: roleParam,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, body, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { teamId } = await params;
|
||||||
|
|
||||||
|
if (!(await canAddUserToTeam(auth))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { userId, role } = body;
|
||||||
|
|
||||||
|
const teamUser = await getTeamUser(teamId, userId);
|
||||||
|
|
||||||
|
if (teamUser) {
|
||||||
|
return badRequest('User is already a member of the Team.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = await createTeamUser(userId, teamId, role);
|
||||||
|
|
||||||
|
return json(users);
|
||||||
|
}
|
||||||
26
src/app/api/teams/[teamId]/websites/route.ts
Normal file
26
src/app/api/teams/[teamId]/websites/route.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { unauthorized, json } from 'lib/response';
|
||||||
|
import { canViewTeam } from 'lib/auth';
|
||||||
|
import { parseRequest } from 'lib/request';
|
||||||
|
import { pagingParams } from 'lib/schema';
|
||||||
|
import { getTeamWebsites } from 'queries';
|
||||||
|
|
||||||
|
export async function GET(request: Request, { params }: { params: Promise<{ teamId: string }> }) {
|
||||||
|
const schema = z.object({
|
||||||
|
...pagingParams,
|
||||||
|
});
|
||||||
|
const { teamId } = await params;
|
||||||
|
const { auth, query, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await canViewTeam(auth, teamId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const websites = await getTeamWebsites(teamId, query);
|
||||||
|
|
||||||
|
return json(websites);
|
||||||
|
}
|
||||||
44
src/app/api/teams/join/route.ts
Normal file
44
src/app/api/teams/join/route.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { unauthorized, json, badRequest, notFound } from 'lib/response';
|
||||||
|
import { canCreateTeam } from 'lib/auth';
|
||||||
|
import { parseRequest } from 'lib/request';
|
||||||
|
import { ROLES } from 'lib/constants';
|
||||||
|
import { createTeamUser, findTeam, getTeamUser } from 'queries';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const schema = z.object({
|
||||||
|
accessCode: z.string().max(50),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, body, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await canCreateTeam(auth))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { accessCode } = body;
|
||||||
|
|
||||||
|
const team = await findTeam({
|
||||||
|
where: {
|
||||||
|
accessCode,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!team) {
|
||||||
|
return notFound('Team not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamUser = await getTeamUser(team.id, auth.user.id);
|
||||||
|
|
||||||
|
if (teamUser) {
|
||||||
|
return badRequest('User is already a team member.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await createTeamUser(auth.user.id, team.id, ROLES.teamMember);
|
||||||
|
|
||||||
|
return json(user);
|
||||||
|
}
|
||||||
36
src/app/api/teams/route.ts
Normal file
36
src/app/api/teams/route.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { getRandomChars } from 'next-basics';
|
||||||
|
import { unauthorized, json } from 'lib/response';
|
||||||
|
import { canCreateTeam } from 'lib/auth';
|
||||||
|
import { uuid } from 'lib/crypto';
|
||||||
|
import { parseRequest } from 'lib/request';
|
||||||
|
import { createTeam } from 'queries';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const schema = z.object({
|
||||||
|
name: z.string().max(50),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, body, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await canCreateTeam(auth))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name } = body;
|
||||||
|
|
||||||
|
const team = await createTeam(
|
||||||
|
{
|
||||||
|
id: uuid(),
|
||||||
|
name,
|
||||||
|
accessCode: `team_${getRandomChars(16)}`,
|
||||||
|
},
|
||||||
|
auth.user.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
return json(team);
|
||||||
|
}
|
||||||
101
src/app/api/users/[userId]/route.ts
Normal file
101
src/app/api/users/[userId]/route.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
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 { parseRequest } from 'lib/request';
|
||||||
|
|
||||||
|
export async function GET(request: Request, { params }: { params: Promise<{ userId: string }> }) {
|
||||||
|
const { auth, error } = await parseRequest(request);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { userId } = await params;
|
||||||
|
|
||||||
|
if (!(await canViewUser(auth, userId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUser(userId);
|
||||||
|
|
||||||
|
return json(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request, { params }: { params: Promise<{ userId: string }> }) {
|
||||||
|
const schema = z.object({
|
||||||
|
username: z.string().max(255),
|
||||||
|
password: z.string().max(255),
|
||||||
|
role: z.string().regex(/admin|user|view-only/i),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, body, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { userId } = await params;
|
||||||
|
|
||||||
|
if (!(await canUpdateUser(auth, userId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { username, password, role } = body;
|
||||||
|
|
||||||
|
const user = await getUser(userId);
|
||||||
|
|
||||||
|
const data: any = {};
|
||||||
|
|
||||||
|
if (password) {
|
||||||
|
data.password = hashPassword(password);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only admin can change these fields
|
||||||
|
if (role && auth.user.isAdmin) {
|
||||||
|
data.role = role;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (username && auth.user.isAdmin) {
|
||||||
|
data.username = username;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check when username changes
|
||||||
|
if (data.username && user.username !== data.username) {
|
||||||
|
const user = await getUserByUsername(username);
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
return badRequest('User already exists');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await updateUser(userId, data);
|
||||||
|
|
||||||
|
return json(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ userId: string }> },
|
||||||
|
) {
|
||||||
|
const { auth, error } = await parseRequest(request);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { userId } = await params;
|
||||||
|
|
||||||
|
if (!(await canDeleteUser(auth))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userId === auth.user.id) {
|
||||||
|
return badRequest('You cannot delete yourself.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteUser(userId);
|
||||||
|
|
||||||
|
return ok();
|
||||||
|
}
|
||||||
27
src/app/api/users/[userId]/teams/route.ts
Normal file
27
src/app/api/users/[userId]/teams/route.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { pagingParams } from 'lib/schema';
|
||||||
|
import { getUserTeams } from 'queries';
|
||||||
|
import { unauthorized, json } from 'lib/response';
|
||||||
|
import { parseRequest } from 'lib/request';
|
||||||
|
|
||||||
|
export async function GET(request: Request, { params }: { params: Promise<{ userId: string }> }) {
|
||||||
|
const schema = z.object({
|
||||||
|
...pagingParams,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, query, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { userId } = await params;
|
||||||
|
|
||||||
|
if (auth.user.id !== userId && !auth.user.isAdmin) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const teams = await getUserTeams(userId, query);
|
||||||
|
|
||||||
|
return json(teams);
|
||||||
|
}
|
||||||
63
src/app/api/users/[userId]/usage/route.ts
Normal file
63
src/app/api/users/[userId]/usage/route.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { json, unauthorized } from 'lib/response';
|
||||||
|
import { getAllUserWebsitesIncludingTeamOwner } from 'queries/prisma/website';
|
||||||
|
import { getEventUsage } from 'queries/analytics/events/getEventUsage';
|
||||||
|
import { getEventDataUsage } from 'queries/analytics/events/getEventDataUsage';
|
||||||
|
import { parseRequest } from 'lib/request';
|
||||||
|
|
||||||
|
export async function GET(request: Request, { params }: { params: Promise<{ userId: string }> }) {
|
||||||
|
const schema = z.object({
|
||||||
|
startAt: z.coerce.number().int(),
|
||||||
|
endAt: z.coerce.number().int(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, query, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!auth.user.isAdmin) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { userId } = await params;
|
||||||
|
const { startAt, endAt } = query;
|
||||||
|
|
||||||
|
const startDate = new Date(+startAt);
|
||||||
|
const endDate = new Date(+endAt);
|
||||||
|
|
||||||
|
const websites = await getAllUserWebsitesIncludingTeamOwner(userId);
|
||||||
|
|
||||||
|
const websiteIds = websites.map(a => a.id);
|
||||||
|
|
||||||
|
const websiteEventUsage = await getEventUsage(websiteIds, startDate, endDate);
|
||||||
|
const eventDataUsage = await getEventDataUsage(websiteIds, startDate, endDate);
|
||||||
|
|
||||||
|
const websiteUsage = websites.map(a => ({
|
||||||
|
websiteId: a.id,
|
||||||
|
websiteName: a.name,
|
||||||
|
websiteEventUsage: websiteEventUsage.find(b => a.id === b.websiteId)?.count || 0,
|
||||||
|
eventDataUsage: eventDataUsage.find(b => a.id === b.websiteId)?.count || 0,
|
||||||
|
deletedAt: a.deletedAt,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const usage = websiteUsage.reduce(
|
||||||
|
(acc, cv) => {
|
||||||
|
acc.websiteEventUsage += cv.websiteEventUsage;
|
||||||
|
acc.eventDataUsage += cv.eventDataUsage;
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{ websiteEventUsage: 0, eventDataUsage: 0 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredWebsiteUsage = websiteUsage.filter(
|
||||||
|
a => !a.deletedAt && (a.websiteEventUsage > 0 || a.eventDataUsage > 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
return json({
|
||||||
|
...usage,
|
||||||
|
websites: filteredWebsiteUsage,
|
||||||
|
});
|
||||||
|
}
|
||||||
27
src/app/api/users/[userId]/websites/route.ts
Normal file
27
src/app/api/users/[userId]/websites/route.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { unauthorized, json } from 'lib/response';
|
||||||
|
import { getUserWebsites } from 'queries/prisma/website';
|
||||||
|
import { pagingParams } from 'lib/schema';
|
||||||
|
import { parseRequest } from 'lib/request';
|
||||||
|
|
||||||
|
export async function GET(request: Request, { params }: { params: Promise<{ userId: string }> }) {
|
||||||
|
const schema = z.object({
|
||||||
|
...pagingParams,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, query, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { userId } = await params;
|
||||||
|
|
||||||
|
if (!auth.user.isAdmin && auth.user.id !== userId) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const websites = await getUserWebsites(userId, query);
|
||||||
|
|
||||||
|
return json(websites);
|
||||||
|
}
|
||||||
44
src/app/api/users/route.ts
Normal file
44
src/app/api/users/route.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { hashPassword } from 'next-basics';
|
||||||
|
import { canCreateUser } from 'lib/auth';
|
||||||
|
import { ROLES } from 'lib/constants';
|
||||||
|
import { uuid } from 'lib/crypto';
|
||||||
|
import { parseRequest } from 'lib/request';
|
||||||
|
import { unauthorized, json, badRequest } from 'lib/response';
|
||||||
|
import { createUser, getUserByUsername } from 'queries';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const schema = z.object({
|
||||||
|
username: z.string().max(255),
|
||||||
|
password: z.string(),
|
||||||
|
id: z.string().uuid(),
|
||||||
|
role: z.string().regex(/admin|user|view-only/i),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, body, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await canCreateUser(auth))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { username, password, role, id } = body;
|
||||||
|
|
||||||
|
const existingUser = await getUserByUsername(username, { showDeleted: true });
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
return badRequest('User already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await createUser({
|
||||||
|
id: id || uuid(),
|
||||||
|
username,
|
||||||
|
password: hashPassword(password),
|
||||||
|
role: role ?? ROLES.user,
|
||||||
|
});
|
||||||
|
|
||||||
|
return json(user);
|
||||||
|
}
|
||||||
6
src/app/api/version/route.ts
Normal file
6
src/app/api/version/route.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { json } from 'lib/response';
|
||||||
|
import { CURRENT_VERSION } from 'lib/constants';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return json({ version: CURRENT_VERSION });
|
||||||
|
}
|
||||||
25
src/app/api/websites/[websiteId]/active/route.ts
Normal file
25
src/app/api/websites/[websiteId]/active/route.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { canViewWebsite } from 'lib/auth';
|
||||||
|
import { json, unauthorized } from 'lib/response';
|
||||||
|
import { getActiveVisitors } from 'queries';
|
||||||
|
import { parseRequest } from 'lib/request';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ websiteId: string }> },
|
||||||
|
) {
|
||||||
|
const { auth, error } = await parseRequest(request);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { websiteId } = await params;
|
||||||
|
|
||||||
|
if (!(await canViewWebsite(auth, websiteId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await getActiveVisitors(websiteId);
|
||||||
|
|
||||||
|
return json(result);
|
||||||
|
}
|
||||||
25
src/app/api/websites/[websiteId]/daterange/route.ts
Normal file
25
src/app/api/websites/[websiteId]/daterange/route.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { canViewWebsite } from 'lib/auth';
|
||||||
|
import { getWebsiteDateRange } from 'queries';
|
||||||
|
import { json, unauthorized } from 'lib/response';
|
||||||
|
import { parseRequest } from 'lib/request';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ websiteId: string }> },
|
||||||
|
) {
|
||||||
|
const { auth, error } = await parseRequest(request);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { websiteId } = await params;
|
||||||
|
|
||||||
|
if (!(await canViewWebsite(auth, websiteId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await getWebsiteDateRange(websiteId);
|
||||||
|
|
||||||
|
return json(result);
|
||||||
|
}
|
||||||
39
src/app/api/websites/[websiteId]/event-data/events/route.ts
Normal file
39
src/app/api/websites/[websiteId]/event-data/events/route.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { parseRequest } from 'lib/request';
|
||||||
|
import { unauthorized, json } from 'lib/response';
|
||||||
|
import { canViewWebsite } from 'lib/auth';
|
||||||
|
import { getEventDataEvents } from 'queries/analytics/events/getEventDataEvents';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ websiteId: string }> },
|
||||||
|
) {
|
||||||
|
const schema = z.object({
|
||||||
|
startAt: z.coerce.number().int(),
|
||||||
|
endAt: z.coerce.number().int(),
|
||||||
|
event: z.string().optional(),
|
||||||
|
});
|
||||||
|
const { auth, query, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { websiteId } = await params;
|
||||||
|
const { startAt, endAt, event } = query;
|
||||||
|
|
||||||
|
if (!(await canViewWebsite(auth, websiteId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDate = new Date(+startAt);
|
||||||
|
const endDate = new Date(+endAt);
|
||||||
|
|
||||||
|
const data = await getEventDataEvents(websiteId, {
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
event,
|
||||||
|
});
|
||||||
|
|
||||||
|
return json(data);
|
||||||
|
}
|
||||||
38
src/app/api/websites/[websiteId]/event-data/fields/route.ts
Normal file
38
src/app/api/websites/[websiteId]/event-data/fields/route.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { parseRequest } from 'lib/request';
|
||||||
|
import { unauthorized, json } from 'lib/response';
|
||||||
|
import { canViewWebsite } from 'lib/auth';
|
||||||
|
import { getEventDataFields } from 'queries';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ websiteId: string }> },
|
||||||
|
) {
|
||||||
|
const schema = z.object({
|
||||||
|
startAt: z.coerce.number().int(),
|
||||||
|
endAt: z.coerce.number().int(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, query, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { websiteId } = await params;
|
||||||
|
const { startAt, endAt } = query;
|
||||||
|
|
||||||
|
if (!(await canViewWebsite(auth, websiteId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDate = new Date(+startAt);
|
||||||
|
const endDate = new Date(+endAt);
|
||||||
|
|
||||||
|
const data = await getEventDataFields(websiteId, {
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
return json(data);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { parseRequest } from 'lib/request';
|
||||||
|
import { unauthorized, json } from 'lib/response';
|
||||||
|
import { canViewWebsite } from 'lib/auth';
|
||||||
|
import { getEventDataProperties } from 'queries';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ websiteId: string }> },
|
||||||
|
) {
|
||||||
|
const schema = z.object({
|
||||||
|
startAt: z.coerce.number().int(),
|
||||||
|
endAt: z.coerce.number().int(),
|
||||||
|
propertyName: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, query, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { websiteId } = await params;
|
||||||
|
const { startAt, endAt, propertyName } = query;
|
||||||
|
|
||||||
|
if (!(await canViewWebsite(auth, websiteId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDate = new Date(+startAt);
|
||||||
|
const endDate = new Date(+endAt);
|
||||||
|
|
||||||
|
const data = await getEventDataProperties(websiteId, { startDate, endDate, propertyName });
|
||||||
|
|
||||||
|
return json(data);
|
||||||
|
}
|
||||||
36
src/app/api/websites/[websiteId]/event-data/stats/route.ts
Normal file
36
src/app/api/websites/[websiteId]/event-data/stats/route.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { parseRequest } from 'lib/request';
|
||||||
|
import { unauthorized, json } from 'lib/response';
|
||||||
|
import { canViewWebsite } from 'lib/auth';
|
||||||
|
import { getEventDataStats } from 'queries';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ websiteId: string }> },
|
||||||
|
) {
|
||||||
|
const schema = z.object({
|
||||||
|
startAt: z.coerce.number().int(),
|
||||||
|
endAt: z.coerce.number().int(),
|
||||||
|
propertyName: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, query, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { websiteId } = await params;
|
||||||
|
const { startAt, endAt } = query;
|
||||||
|
|
||||||
|
if (!(await canViewWebsite(auth, websiteId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDate = new Date(+startAt);
|
||||||
|
const endDate = new Date(+endAt);
|
||||||
|
|
||||||
|
const data = await getEventDataStats(websiteId, { startDate, endDate });
|
||||||
|
|
||||||
|
return json(data);
|
||||||
|
}
|
||||||
42
src/app/api/websites/[websiteId]/event-data/values/route.ts
Normal file
42
src/app/api/websites/[websiteId]/event-data/values/route.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { parseRequest } from 'lib/request';
|
||||||
|
import { unauthorized, json } from 'lib/response';
|
||||||
|
import { canViewWebsite } from 'lib/auth';
|
||||||
|
import { getEventDataValues } from 'queries';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ websiteId: string }> },
|
||||||
|
) {
|
||||||
|
const schema = z.object({
|
||||||
|
startAt: z.coerce.number().int(),
|
||||||
|
endAt: z.coerce.number().int(),
|
||||||
|
eventName: z.string().optional(),
|
||||||
|
propertyName: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, query, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { websiteId } = await params;
|
||||||
|
const { startAt, endAt, eventName, propertyName } = query;
|
||||||
|
|
||||||
|
if (!(await canViewWebsite(auth, websiteId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDate = new Date(+startAt);
|
||||||
|
const endDate = new Date(+endAt);
|
||||||
|
|
||||||
|
const data = await getEventDataValues(websiteId, {
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
eventName,
|
||||||
|
propertyName,
|
||||||
|
});
|
||||||
|
|
||||||
|
return json(data);
|
||||||
|
}
|
||||||
37
src/app/api/websites/[websiteId]/events/route.ts
Normal file
37
src/app/api/websites/[websiteId]/events/route.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { parseRequest } from 'lib/request';
|
||||||
|
import { unauthorized, json } from 'lib/response';
|
||||||
|
import { canViewWebsite } from 'lib/auth';
|
||||||
|
import { pagingParams } from 'lib/schema';
|
||||||
|
import { getWebsiteEvents } from 'queries';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ websiteId: string }> },
|
||||||
|
) {
|
||||||
|
const schema = z.object({
|
||||||
|
startAt: z.coerce.number().int(),
|
||||||
|
endAt: z.coerce.number().int(),
|
||||||
|
...pagingParams,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, query, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { websiteId } = await params;
|
||||||
|
const { startAt, endAt } = query;
|
||||||
|
|
||||||
|
if (!(await canViewWebsite(auth, websiteId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDate = new Date(+startAt);
|
||||||
|
const endDate = new Date(+endAt);
|
||||||
|
|
||||||
|
const data = await getWebsiteEvents(websiteId, { startDate, endDate }, query);
|
||||||
|
|
||||||
|
return json(data);
|
||||||
|
}
|
||||||
45
src/app/api/websites/[websiteId]/events/series/route.ts
Normal file
45
src/app/api/websites/[websiteId]/events/series/route.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { parseRequest, getRequestDateRange, getRequestFilters } from 'lib/request';
|
||||||
|
import { unauthorized, json } from 'lib/response';
|
||||||
|
import { canViewWebsite } from 'lib/auth';
|
||||||
|
import { filterParams, timezoneParam, unitParam } from 'lib/schema';
|
||||||
|
import { getEventMetrics } from 'queries';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ websiteId: string }> },
|
||||||
|
) {
|
||||||
|
const schema = z.object({
|
||||||
|
startAt: z.coerce.number().int(),
|
||||||
|
endAt: z.coerce.number().int(),
|
||||||
|
unit: unitParam,
|
||||||
|
timezone: timezoneParam,
|
||||||
|
...filterParams,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, query, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { websiteId } = await params;
|
||||||
|
const { timezone } = query;
|
||||||
|
const { startDate, endDate, unit } = await getRequestDateRange(query);
|
||||||
|
|
||||||
|
if (!(await canViewWebsite(auth, websiteId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters = {
|
||||||
|
...getRequestFilters(request),
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
timezone,
|
||||||
|
unit,
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = await getEventMetrics(websiteId, filters);
|
||||||
|
|
||||||
|
return json(data);
|
||||||
|
}
|
||||||
82
src/app/api/websites/[websiteId]/metrics/route.ts
Normal file
82
src/app/api/websites/[websiteId]/metrics/route.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { canViewWebsite } from 'lib/auth';
|
||||||
|
import { SESSION_COLUMNS, EVENT_COLUMNS, FILTER_COLUMNS, OPERATORS } from 'lib/constants';
|
||||||
|
import { getRequestFilters, getRequestDateRange, parseRequest } from 'lib/request';
|
||||||
|
import { json, unauthorized, badRequest } from 'lib/response';
|
||||||
|
import { getPageviewMetrics, getSessionMetrics } from 'queries';
|
||||||
|
import { filterParams } from 'lib/schema';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ websiteId: string }> },
|
||||||
|
) {
|
||||||
|
const schema = z.object({
|
||||||
|
type: z.string(),
|
||||||
|
startAt: z.coerce.number().int(),
|
||||||
|
endAt: z.coerce.number().int(),
|
||||||
|
limit: z.coerce.number().optional(),
|
||||||
|
offset: z.coerce.number().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
...filterParams,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, query, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { websiteId } = await params;
|
||||||
|
const { type, limit, offset, search } = query;
|
||||||
|
|
||||||
|
if (!(await canViewWebsite(auth, websiteId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { startDate, endDate } = await getRequestDateRange(query);
|
||||||
|
const column = FILTER_COLUMNS[type] || type;
|
||||||
|
const filters = {
|
||||||
|
...getRequestFilters(query),
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
filters[type] = {
|
||||||
|
name: type,
|
||||||
|
column,
|
||||||
|
operator: OPERATORS.contains,
|
||||||
|
value: search,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SESSION_COLUMNS.includes(type)) {
|
||||||
|
const data = await getSessionMetrics(websiteId, type, filters, limit, offset);
|
||||||
|
|
||||||
|
if (type === 'language') {
|
||||||
|
const combined = {};
|
||||||
|
|
||||||
|
for (const { x, y } of data) {
|
||||||
|
const key = String(x).toLowerCase().split('-')[0];
|
||||||
|
|
||||||
|
if (combined[key] === undefined) {
|
||||||
|
combined[key] = { x: key, y };
|
||||||
|
} else {
|
||||||
|
combined[key].y += y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return json(Object.values(combined));
|
||||||
|
}
|
||||||
|
|
||||||
|
return json(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (EVENT_COLUMNS.includes(type)) {
|
||||||
|
const data = await getPageviewMetrics(websiteId, type, filters, limit, offset);
|
||||||
|
|
||||||
|
return json(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return badRequest();
|
||||||
|
}
|
||||||
85
src/app/api/websites/[websiteId]/pageviews/route.ts
Normal file
85
src/app/api/websites/[websiteId]/pageviews/route.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { canViewWebsite } from 'lib/auth';
|
||||||
|
import { getRequestFilters, getRequestDateRange, parseRequest } from 'lib/request';
|
||||||
|
import { unitParam, timezoneParam, filterParams } from 'lib/schema';
|
||||||
|
import { getCompareDate } from 'lib/date';
|
||||||
|
import { unauthorized, json } from 'lib/response';
|
||||||
|
import { getPageviewStats, getSessionStats } from 'queries';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ websiteId: string }> },
|
||||||
|
) {
|
||||||
|
const schema = z.object({
|
||||||
|
startAt: z.coerce.number().int(),
|
||||||
|
endAt: z.coerce.number().int(),
|
||||||
|
unit: unitParam,
|
||||||
|
timezone: timezoneParam,
|
||||||
|
compare: z.string().optional(),
|
||||||
|
...filterParams,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, query, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { websiteId } = await params;
|
||||||
|
const { timezone, compare } = query;
|
||||||
|
|
||||||
|
if (!(await canViewWebsite(auth, websiteId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { startDate, endDate, unit } = await getRequestDateRange(query);
|
||||||
|
|
||||||
|
const filters = {
|
||||||
|
...getRequestFilters(query),
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
timezone,
|
||||||
|
unit,
|
||||||
|
};
|
||||||
|
|
||||||
|
const [pageviews, sessions] = await Promise.all([
|
||||||
|
getPageviewStats(websiteId, filters),
|
||||||
|
getSessionStats(websiteId, filters),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (compare) {
|
||||||
|
const { startDate: compareStartDate, endDate: compareEndDate } = getCompareDate(
|
||||||
|
compare,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [comparePageviews, compareSessions] = await Promise.all([
|
||||||
|
getPageviewStats(websiteId, {
|
||||||
|
...filters,
|
||||||
|
startDate: compareStartDate,
|
||||||
|
endDate: compareEndDate,
|
||||||
|
}),
|
||||||
|
getSessionStats(websiteId, {
|
||||||
|
...filters,
|
||||||
|
startDate: compareStartDate,
|
||||||
|
endDate: compareEndDate,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return json({
|
||||||
|
pageviews,
|
||||||
|
sessions,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
compare: {
|
||||||
|
pageviews: comparePageviews,
|
||||||
|
sessions: compareSessions,
|
||||||
|
startDate: compareStartDate,
|
||||||
|
endDate: compareEndDate,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ pageviews, sessions });
|
||||||
|
}
|
||||||
36
src/app/api/websites/[websiteId]/reports/route.ts
Normal file
36
src/app/api/websites/[websiteId]/reports/route.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { canViewWebsite } from 'lib/auth';
|
||||||
|
import { getWebsiteReports } from 'queries';
|
||||||
|
import { pagingParams } from 'lib/schema';
|
||||||
|
import { parseRequest } from 'lib/request';
|
||||||
|
import { unauthorized, json } from 'lib/response';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ websiteId: string }> },
|
||||||
|
) {
|
||||||
|
const schema = z.object({
|
||||||
|
...pagingParams,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, query, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { websiteId } = await params;
|
||||||
|
const { page, pageSize, search } = query;
|
||||||
|
|
||||||
|
if (!(await canViewWebsite(auth, websiteId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await getWebsiteReports(websiteId, {
|
||||||
|
page: +page,
|
||||||
|
pageSize: +pageSize,
|
||||||
|
search,
|
||||||
|
});
|
||||||
|
|
||||||
|
return json(data);
|
||||||
|
}
|
||||||
25
src/app/api/websites/[websiteId]/reset/route.ts
Normal file
25
src/app/api/websites/[websiteId]/reset/route.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { canUpdateWebsite } from 'lib/auth';
|
||||||
|
import { resetWebsite } from 'queries';
|
||||||
|
import { unauthorized, ok } from 'lib/response';
|
||||||
|
import { parseRequest } from 'lib/request';
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ websiteId: string }> },
|
||||||
|
) {
|
||||||
|
const { auth, error } = await parseRequest(request);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { websiteId } = await params;
|
||||||
|
|
||||||
|
if (!(await canUpdateWebsite(auth, websiteId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
await resetWebsite(websiteId);
|
||||||
|
|
||||||
|
return ok();
|
||||||
|
}
|
||||||
84
src/app/api/websites/[websiteId]/route.ts
Normal file
84
src/app/api/websites/[websiteId]/route.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { canUpdateWebsite, canDeleteWebsite, canViewWebsite } from 'lib/auth';
|
||||||
|
import { SHARE_ID_REGEX } from 'lib/constants';
|
||||||
|
import { parseRequest } from 'lib/request';
|
||||||
|
import { ok, json, unauthorized, serverError } from 'lib/response';
|
||||||
|
import { deleteWebsite, getWebsite, updateWebsite } from 'queries';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ websiteId: string }> },
|
||||||
|
) {
|
||||||
|
const { auth, error } = await parseRequest(request);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { websiteId } = await params;
|
||||||
|
|
||||||
|
if (!(await canViewWebsite(auth, websiteId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const website = await getWebsite(websiteId);
|
||||||
|
|
||||||
|
return json(website);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ websiteId: string }> },
|
||||||
|
) {
|
||||||
|
const schema = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
domain: z.string(),
|
||||||
|
shareId: z.string().regex(SHARE_ID_REGEX).nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, body, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { websiteId } = await params;
|
||||||
|
const { name, domain, shareId } = body;
|
||||||
|
|
||||||
|
if (!(await canUpdateWebsite(auth, websiteId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const website = await updateWebsite(websiteId, { name, domain, shareId });
|
||||||
|
|
||||||
|
return Response.json(website);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.message.includes('Unique constraint') && e.message.includes('share_id')) {
|
||||||
|
return serverError(new Error('That share ID is already taken.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return serverError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ websiteId: string }> },
|
||||||
|
) {
|
||||||
|
const { auth, error } = await parseRequest(request);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { websiteId } = await params;
|
||||||
|
|
||||||
|
if (!(await canDeleteWebsite(auth, websiteId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteWebsite(websiteId);
|
||||||
|
|
||||||
|
return ok();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { parseRequest } from 'lib/request';
|
||||||
|
import { unauthorized, json } from 'lib/response';
|
||||||
|
import { canViewWebsite } from 'lib/auth';
|
||||||
|
import { getSessionDataProperties } from 'queries';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ websiteId: string }> },
|
||||||
|
) {
|
||||||
|
const schema = z.object({
|
||||||
|
startAt: z.coerce.number().int(),
|
||||||
|
endAt: z.coerce.number().int(),
|
||||||
|
propertyName: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, query, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { startAt, endAt, propertyName } = query;
|
||||||
|
const { websiteId } = await params;
|
||||||
|
|
||||||
|
if (!(await canViewWebsite(auth, websiteId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDate = new Date(+startAt);
|
||||||
|
const endDate = new Date(+endAt);
|
||||||
|
|
||||||
|
const data = await getSessionDataProperties(websiteId, { startDate, endDate, propertyName });
|
||||||
|
|
||||||
|
return json(data);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { parseRequest } from 'lib/request';
|
||||||
|
import { unauthorized, json } from 'lib/response';
|
||||||
|
import { canViewWebsite } from 'lib/auth';
|
||||||
|
import { getEventDataEvents } from 'queries/analytics/events/getEventDataEvents';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ websiteId: string }> },
|
||||||
|
) {
|
||||||
|
const schema = z.object({
|
||||||
|
startAt: z.coerce.number().int(),
|
||||||
|
endAt: z.coerce.number().int(),
|
||||||
|
propertyName: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, query, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { startAt, endAt, event } = query;
|
||||||
|
const { websiteId } = await params;
|
||||||
|
|
||||||
|
if (!(await canViewWebsite(auth, websiteId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDate = new Date(+startAt);
|
||||||
|
const endDate = new Date(+endAt);
|
||||||
|
|
||||||
|
const data = await getEventDataEvents(websiteId, {
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
event,
|
||||||
|
});
|
||||||
|
|
||||||
|
return json(data);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { parseRequest } from 'lib/request';
|
||||||
|
import { unauthorized, json } from 'lib/response';
|
||||||
|
import { canViewWebsite } from 'lib/auth';
|
||||||
|
import { getSessionActivity } from 'queries';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ websiteId: string; sessionId: string }> },
|
||||||
|
) {
|
||||||
|
const schema = z.object({
|
||||||
|
startAt: z.coerce.number().int(),
|
||||||
|
endAt: z.coerce.number().int(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, query, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { websiteId, sessionId } = await params;
|
||||||
|
const { startAt, endAt } = query;
|
||||||
|
|
||||||
|
if (!(await canViewWebsite(auth, websiteId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDate = new Date(+startAt);
|
||||||
|
const endDate = new Date(+endAt);
|
||||||
|
|
||||||
|
const data = await getSessionActivity(websiteId, sessionId, startDate, endDate);
|
||||||
|
|
||||||
|
return json(data);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { unauthorized, json } from 'lib/response';
|
||||||
|
import { canViewWebsite } from 'lib/auth';
|
||||||
|
import { getSessionData } from 'queries';
|
||||||
|
import { parseRequest } from 'lib/request';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ websiteId: string; sessionId: string }> },
|
||||||
|
) {
|
||||||
|
const { auth, error } = await parseRequest(request);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { websiteId, sessionId } = await params;
|
||||||
|
|
||||||
|
if (!(await canViewWebsite(auth, websiteId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await getSessionData(websiteId, sessionId);
|
||||||
|
|
||||||
|
return json(data);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { unauthorized, json } from 'lib/response';
|
||||||
|
import { canViewWebsite } from 'lib/auth';
|
||||||
|
import { getWebsiteSession } from 'queries';
|
||||||
|
import { parseRequest } from 'lib/request';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ websiteId: string; sessionId: string }> },
|
||||||
|
) {
|
||||||
|
const { auth, error } = await parseRequest(request);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { websiteId, sessionId } = await params;
|
||||||
|
|
||||||
|
if (!(await canViewWebsite(auth, websiteId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await getWebsiteSession(websiteId, sessionId);
|
||||||
|
|
||||||
|
return json(data);
|
||||||
|
}
|
||||||
37
src/app/api/websites/[websiteId]/sessions/route.ts
Normal file
37
src/app/api/websites/[websiteId]/sessions/route.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { parseRequest } from 'lib/request';
|
||||||
|
import { unauthorized, json } from 'lib/response';
|
||||||
|
import { canViewWebsite } from 'lib/auth';
|
||||||
|
import { pagingParams } from 'lib/schema';
|
||||||
|
import { getWebsiteSessions } from 'queries';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ websiteId: string }> },
|
||||||
|
) {
|
||||||
|
const schema = z.object({
|
||||||
|
startAt: z.coerce.number().int(),
|
||||||
|
endAt: z.coerce.number().int(),
|
||||||
|
...pagingParams,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, query, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { websiteId } = await params;
|
||||||
|
const { startAt, endAt } = query;
|
||||||
|
|
||||||
|
if (!(await canViewWebsite(auth, websiteId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDate = new Date(+startAt);
|
||||||
|
const endDate = new Date(+endAt);
|
||||||
|
|
||||||
|
const data = await getWebsiteSessions(websiteId, { startDate, endDate }, query);
|
||||||
|
|
||||||
|
return json(data);
|
||||||
|
}
|
||||||
48
src/app/api/websites/[websiteId]/sessions/stats/route.ts
Normal file
48
src/app/api/websites/[websiteId]/sessions/stats/route.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { parseRequest, getRequestDateRange, getRequestFilters } from 'lib/request';
|
||||||
|
import { unauthorized, json } from 'lib/response';
|
||||||
|
import { canViewWebsite } from 'lib/auth';
|
||||||
|
import { filterParams } from 'lib/schema';
|
||||||
|
import { getWebsiteSessionStats } from 'queries';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ websiteId: string }> },
|
||||||
|
) {
|
||||||
|
const schema = z.object({
|
||||||
|
startAt: z.coerce.number().int(),
|
||||||
|
endAt: z.coerce.number().int(),
|
||||||
|
...filterParams,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, query, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { websiteId } = await params;
|
||||||
|
|
||||||
|
if (!(await canViewWebsite(auth, websiteId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { startDate, endDate } = await getRequestDateRange(query);
|
||||||
|
|
||||||
|
const filters = getRequestFilters(request);
|
||||||
|
|
||||||
|
const metrics = await getWebsiteSessionStats(websiteId, {
|
||||||
|
...filters,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = Object.keys(metrics[0]).reduce((obj, key) => {
|
||||||
|
obj[key] = {
|
||||||
|
value: Number(metrics[0][key]) || 0,
|
||||||
|
};
|
||||||
|
return obj;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return json(data);
|
||||||
|
}
|
||||||
38
src/app/api/websites/[websiteId]/sessions/weekly/route.ts
Normal file
38
src/app/api/websites/[websiteId]/sessions/weekly/route.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { parseRequest } from 'lib/request';
|
||||||
|
import { unauthorized, json } from 'lib/response';
|
||||||
|
import { canViewWebsite } from 'lib/auth';
|
||||||
|
import { pagingParams, timezoneParam } from 'lib/schema';
|
||||||
|
import { getWebsiteSessionsWeekly } from 'queries';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ websiteId: string }> },
|
||||||
|
) {
|
||||||
|
const schema = z.object({
|
||||||
|
startAt: z.coerce.number().int(),
|
||||||
|
endAt: z.coerce.number().int(),
|
||||||
|
timezone: timezoneParam,
|
||||||
|
...pagingParams,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, query, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { websiteId } = await params;
|
||||||
|
const { startAt, endAt, timezone } = query;
|
||||||
|
|
||||||
|
if (!(await canViewWebsite(auth, websiteId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDate = new Date(+startAt);
|
||||||
|
const endDate = new Date(+endAt);
|
||||||
|
|
||||||
|
const data = await getWebsiteSessionsWeekly(websiteId, { startDate, endDate, timezone });
|
||||||
|
|
||||||
|
return json(data);
|
||||||
|
}
|
||||||
63
src/app/api/websites/[websiteId]/stats/route.ts
Normal file
63
src/app/api/websites/[websiteId]/stats/route.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { parseRequest, getRequestDateRange, getRequestFilters } from 'lib/request';
|
||||||
|
import { unauthorized, json } from 'lib/response';
|
||||||
|
import { canViewWebsite } from 'lib/auth';
|
||||||
|
import { getCompareDate } from 'lib/date';
|
||||||
|
import { filterParams } from 'lib/schema';
|
||||||
|
import { getWebsiteStats } from 'queries';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ websiteId: string }> },
|
||||||
|
) {
|
||||||
|
const schema = z.object({
|
||||||
|
startAt: z.coerce.number().int(),
|
||||||
|
endAt: z.coerce.number().int(),
|
||||||
|
compare: z.string().optional(),
|
||||||
|
...filterParams,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, query, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { websiteId } = await params;
|
||||||
|
const { compare } = query;
|
||||||
|
|
||||||
|
if (!(await canViewWebsite(auth, websiteId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { startDate, endDate } = await getRequestDateRange(query);
|
||||||
|
const { startDate: compareStartDate, endDate: compareEndDate } = getCompareDate(
|
||||||
|
compare,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
);
|
||||||
|
|
||||||
|
const filters = getRequestFilters(query);
|
||||||
|
|
||||||
|
const metrics = await getWebsiteStats(websiteId, {
|
||||||
|
...filters,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const prevPeriod = await getWebsiteStats(websiteId, {
|
||||||
|
...filters,
|
||||||
|
startDate: compareStartDate,
|
||||||
|
endDate: compareEndDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stats = Object.keys(metrics[0]).reduce((obj, key) => {
|
||||||
|
obj[key] = {
|
||||||
|
value: Number(metrics[0][key]) || 0,
|
||||||
|
prev: Number(prevPeriod[0][key]) || 0,
|
||||||
|
};
|
||||||
|
return obj;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return json(stats);
|
||||||
|
}
|
||||||
50
src/app/api/websites/[websiteId]/transfer/route.ts
Normal file
50
src/app/api/websites/[websiteId]/transfer/route.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { canTransferWebsiteToTeam, canTransferWebsiteToUser } from 'lib/auth';
|
||||||
|
import { updateWebsite } from 'queries';
|
||||||
|
import { parseRequest } from 'lib/request';
|
||||||
|
import { badRequest, unauthorized, json } from 'lib/response';
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ websiteId: string }> },
|
||||||
|
) {
|
||||||
|
const schema = z.object({
|
||||||
|
userId: z.string().uuid().optional(),
|
||||||
|
teamId: z.string().uuid().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, body, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { websiteId } = await params;
|
||||||
|
const { userId, teamId } = body;
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
if (!(await canTransferWebsiteToUser(auth, websiteId, userId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const website = await updateWebsite(websiteId, {
|
||||||
|
userId,
|
||||||
|
teamId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return json(website);
|
||||||
|
} else if (teamId) {
|
||||||
|
if (!(await canTransferWebsiteToTeam(auth, websiteId, teamId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const website = await updateWebsite(websiteId, {
|
||||||
|
userId: null,
|
||||||
|
teamId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return json(website);
|
||||||
|
}
|
||||||
|
|
||||||
|
return badRequest();
|
||||||
|
}
|
||||||
40
src/app/api/websites/[websiteId]/values/route.ts
Normal file
40
src/app/api/websites/[websiteId]/values/route.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { canViewWebsite } from 'lib/auth';
|
||||||
|
import { EVENT_COLUMNS, FILTER_COLUMNS, SESSION_COLUMNS } from 'lib/constants';
|
||||||
|
import { getValues } from 'queries';
|
||||||
|
import { parseRequest, getRequestDateRange } from 'lib/request';
|
||||||
|
import { badRequest, json, unauthorized } from 'lib/response';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ websiteId: string }> },
|
||||||
|
) {
|
||||||
|
const schema = z.object({
|
||||||
|
type: z.string(),
|
||||||
|
startAt: z.coerce.number().int(),
|
||||||
|
endAt: z.coerce.number().int(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, query, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { websiteId } = await params;
|
||||||
|
const { type, search } = query;
|
||||||
|
const { startDate, endDate } = await getRequestDateRange(query);
|
||||||
|
|
||||||
|
if (!(await canViewWebsite(auth, websiteId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SESSION_COLUMNS.includes(type) && !EVENT_COLUMNS.includes(type)) {
|
||||||
|
return badRequest('Invalid type.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = await getValues(websiteId, FILTER_COLUMNS[type], startDate, endDate, search);
|
||||||
|
|
||||||
|
return json(values.filter(n => n).sort());
|
||||||
|
}
|
||||||
59
src/app/api/websites/route.ts
Normal file
59
src/app/api/websites/route.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { canCreateTeamWebsite, canCreateWebsite } from 'lib/auth';
|
||||||
|
import { json, unauthorized } from 'lib/response';
|
||||||
|
import { uuid } from 'lib/crypto';
|
||||||
|
import { parseRequest } from 'lib/request';
|
||||||
|
import { createWebsite, getUserWebsites } from 'queries';
|
||||||
|
import { pagingParams } from 'lib/schema';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const schema = z.object({ ...pagingParams });
|
||||||
|
|
||||||
|
const { auth, query, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const websites = await getUserWebsites(auth.user.id, query);
|
||||||
|
|
||||||
|
return json(websites);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const schema = z.object({
|
||||||
|
name: z.string().max(100),
|
||||||
|
domain: z.string().max(500),
|
||||||
|
shareId: z.string().max(50).nullable().optional(),
|
||||||
|
teamId: z.string().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, body, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, domain, shareId, teamId } = body;
|
||||||
|
|
||||||
|
if ((teamId && !(await canCreateTeamWebsite(auth, teamId))) || !(await canCreateWebsite(auth))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: any = {
|
||||||
|
id: uuid(),
|
||||||
|
createdBy: auth.user.id,
|
||||||
|
name,
|
||||||
|
domain,
|
||||||
|
shareId,
|
||||||
|
teamId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!teamId) {
|
||||||
|
data.userId = auth.user.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const website = await createWebsite(data);
|
||||||
|
|
||||||
|
return json(website);
|
||||||
|
}
|
||||||
|
|
@ -37,26 +37,26 @@ export function DataTable({
|
||||||
query: { error, isLoading, isFetched },
|
query: { error, isLoading, isFetched },
|
||||||
} = queryResult || {};
|
} = queryResult || {};
|
||||||
const { page, pageSize, count, data } = result || {};
|
const { page, pageSize, count, data } = result || {};
|
||||||
const { query } = params || {};
|
const { search } = params || {};
|
||||||
const hasData = Boolean(!isLoading && data?.length);
|
const hasData = Boolean(!isLoading && data?.length);
|
||||||
const noResults = Boolean(query && !hasData);
|
const noResults = Boolean(search && !hasData);
|
||||||
const { router, renderUrl } = useNavigation();
|
const { router, renderUrl } = useNavigation();
|
||||||
|
|
||||||
const handleSearch = (query: string) => {
|
const handleSearch = (search: string) => {
|
||||||
setParams({ ...params, query, page: params.page ? page : 1 });
|
setParams({ ...params, search, page: params.page ? page : 1 });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePageChange = (page: number) => {
|
const handlePageChange = (page: number) => {
|
||||||
setParams({ ...params, query, page });
|
setParams({ ...params, search, page });
|
||||||
router.push(renderUrl({ page }));
|
router.push(renderUrl({ page }));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{allowSearch && (hasData || query) && (
|
{allowSearch && (hasData || search) && (
|
||||||
<SearchField
|
<SearchField
|
||||||
className={styles.search}
|
className={styles.search}
|
||||||
value={query}
|
value={search}
|
||||||
onSearch={handleSearch}
|
onSearch={handleSearch}
|
||||||
delay={searchDelay || DEFAULT_SEARCH_DELAY}
|
delay={searchDelay || DEFAULT_SEARCH_DELAY}
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
|
|
@ -71,7 +71,7 @@ export function DataTable({
|
||||||
>
|
>
|
||||||
{hasData ? (typeof children === 'function' ? children(result) : children) : null}
|
{hasData ? (typeof children === 'function' ? children(result) : children) : null}
|
||||||
{isLoading && <Loading position="page" />}
|
{isLoading && <Loading position="page" />}
|
||||||
{!isLoading && !hasData && !query && (renderEmpty ? renderEmpty() : <Empty />)}
|
{!isLoading && !hasData && !search && (renderEmpty ? renderEmpty() : <Empty />)}
|
||||||
{!isLoading && noResults && <Empty message={formatMessage(messages.noResultsFound)} />}
|
{!isLoading && noResults && <Empty message={formatMessage(messages.noResultsFound)} />}
|
||||||
</div>
|
</div>
|
||||||
{allowPaging && hasData && (
|
{allowPaging && hasData && (
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,16 @@
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import useStore, { setConfig } from 'store/app';
|
import useStore, { setConfig } from 'store/app';
|
||||||
import { useApi } from '../useApi';
|
import { getConfig } from 'app/actions/getConfig';
|
||||||
|
|
||||||
let loading = false;
|
|
||||||
|
|
||||||
export function useConfig() {
|
export function useConfig() {
|
||||||
const { config } = useStore();
|
const { config } = useStore();
|
||||||
const { get } = useApi();
|
|
||||||
const configUrl = process.env.configUrl;
|
|
||||||
|
|
||||||
async function loadConfig() {
|
async function loadConfig() {
|
||||||
const data = await get(configUrl);
|
setConfig(await getConfig());
|
||||||
loading = false;
|
|
||||||
setConfig(data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!config && !loading && configUrl) {
|
if (!config) {
|
||||||
loading = true;
|
|
||||||
loadConfig();
|
loadConfig();
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { UseQueryOptions } from '@tanstack/react-query';
|
import { UseQueryOptions } from '@tanstack/react-query';
|
||||||
import { useApi } from '../useApi';
|
import { useApi } from '../useApi';
|
||||||
import { useFilterParams } from '..//useFilterParams';
|
import { useFilterParams } from '../useFilterParams';
|
||||||
|
|
||||||
export function useWebsitePageviews(
|
export function useWebsitePageviews(
|
||||||
websiteId: string,
|
websiteId: string,
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@ export function usePagedQuery<T = any>({
|
||||||
}: Omit<UseQueryOptions, 'queryFn'> & { queryFn: (params?: object) => any }): PagedQueryResult<T> {
|
}: Omit<UseQueryOptions, 'queryFn'> & { queryFn: (params?: object) => any }): PagedQueryResult<T> {
|
||||||
const { query: queryParams } = useNavigation();
|
const { query: queryParams } = useNavigation();
|
||||||
const [params, setParams] = useState<PageParams>({
|
const [params, setParams] = useState<PageParams>({
|
||||||
query: '',
|
search: '',
|
||||||
page: +queryParams.page || 1,
|
page: queryParams.page || '1',
|
||||||
});
|
});
|
||||||
|
|
||||||
const { useQuery } = useApi();
|
const { useQuery } = useApi();
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,3 @@
|
||||||
font-size: var(--font-size-md);
|
font-size: var(--font-size-md);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
.value {
|
|
||||||
font-weight: 600;
|
|
||||||
margin-inline-end: 4px;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ export function ActiveUsers({
|
||||||
|
|
||||||
const count = useMemo(() => {
|
const count = useMemo(() => {
|
||||||
if (websiteId) {
|
if (websiteId) {
|
||||||
return data?.x || 0;
|
return data?.visitors || 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
return value !== undefined ? value : 0;
|
return value !== undefined ? value : 0;
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,17 @@ const IP = '127.0.0.1';
|
||||||
test('getIpAddress: Custom header', () => {
|
test('getIpAddress: Custom header', () => {
|
||||||
process.env.CLIENT_IP_HEADER = 'x-custom-ip-header';
|
process.env.CLIENT_IP_HEADER = 'x-custom-ip-header';
|
||||||
|
|
||||||
expect(detect.getIpAddress({ headers: { 'x-custom-ip-header': IP } } as any)).toEqual(IP);
|
expect(detect.getIpAddress(new Headers({ 'x-custom-ip-header': IP }))).toEqual(IP);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getIpAddress: CloudFlare header', () => {
|
test('getIpAddress: CloudFlare header', () => {
|
||||||
expect(detect.getIpAddress({ headers: { 'cf-connecting-ip': IP } } as any)).toEqual(IP);
|
expect(detect.getIpAddress(new Headers({ 'cf-connecting-ip': IP }))).toEqual(IP);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getIpAddress: Standard header', () => {
|
test('getIpAddress: Standard header', () => {
|
||||||
expect(detect.getIpAddress({ headers: { 'x-forwarded-for': IP } } as any)).toEqual(IP);
|
expect(detect.getIpAddress(new Headers({ 'x-forwarded-for': IP }))).toEqual(IP);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getIpAddress: No header', () => {
|
test('getIpAddress: No header', () => {
|
||||||
expect(detect.getIpAddress({ headers: {} } as any)).toEqual(null);
|
expect(detect.getIpAddress(new Headers())).toEqual(null);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,64 @@
|
||||||
import { Report } from '@prisma/client';
|
import { Report } from '@prisma/client';
|
||||||
import { getClient } from '@umami/redis-client';
|
import { getClient, redisEnabled } from '@umami/redis-client';
|
||||||
import debug from 'debug';
|
import debug from 'debug';
|
||||||
import { PERMISSIONS, ROLE_PERMISSIONS, SHARE_TOKEN_HEADER } from 'lib/constants';
|
import { PERMISSIONS, ROLE_PERMISSIONS, ROLES, SHARE_TOKEN_HEADER } from 'lib/constants';
|
||||||
import { secret } from 'lib/crypto';
|
import { secret } from 'lib/crypto';
|
||||||
import { NextApiRequest } from 'next';
|
import { NextApiRequest } from 'next';
|
||||||
import { createSecureToken, ensureArray, getRandomChars, parseToken } from 'next-basics';
|
import {
|
||||||
import { getTeamUser, getWebsite } from 'queries';
|
createSecureToken,
|
||||||
|
ensureArray,
|
||||||
|
getRandomChars,
|
||||||
|
parseSecureToken,
|
||||||
|
parseToken,
|
||||||
|
} from 'next-basics';
|
||||||
|
import { getTeamUser, getUser, getWebsite } from 'queries';
|
||||||
import { Auth } from './types';
|
import { Auth } from './types';
|
||||||
|
|
||||||
const log = debug('umami:auth');
|
const log = debug('umami:auth');
|
||||||
const cloudMode = process.env.CLOUD_MODE;
|
const cloudMode = process.env.CLOUD_MODE;
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
let user = null;
|
||||||
|
const { userId, authKey, grant } = payload || {};
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
user = await getUser(userId);
|
||||||
|
} else if (redisEnabled && authKey) {
|
||||||
|
const redis = getClient();
|
||||||
|
|
||||||
|
const key = await redis.get(authKey);
|
||||||
|
|
||||||
|
if (key?.userId) {
|
||||||
|
user = await getUser(key.userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
log('checkAuth:', { token, shareToken, payload, user, grant });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user?.id && !shareToken) {
|
||||||
|
log('checkAuth: User not authorized');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
user.isAdmin = user.role === ROLES.admin;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
grant,
|
||||||
|
token,
|
||||||
|
shareToken,
|
||||||
|
authKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function saveAuth(data: any, expire = 0) {
|
export async function saveAuth(data: any, expire = 0) {
|
||||||
const authKey = `auth:${getRandomChars(32)}`;
|
const authKey = `auth:${getRandomChars(32)}`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -324,6 +324,20 @@ export const BROWSERS = {
|
||||||
yandexbrowser: 'Yandex',
|
yandexbrowser: 'Yandex',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const IP_ADDRESS_HEADERS = [
|
||||||
|
'cf-connecting-ip',
|
||||||
|
'x-client-ip',
|
||||||
|
'x-forwarded-for',
|
||||||
|
'do-connecting-ip',
|
||||||
|
'fastly-client-ip',
|
||||||
|
'true-client-ip',
|
||||||
|
'x-real-ip',
|
||||||
|
'x-cluster-client-ip',
|
||||||
|
'x-forwarded',
|
||||||
|
'forwarded',
|
||||||
|
'x-appengine-user-ip',
|
||||||
|
];
|
||||||
|
|
||||||
export const MAP_FILE = '/datamaps.world.json';
|
export const MAP_FILE = '/datamaps.world.json';
|
||||||
|
|
||||||
export const ISO_COUNTRIES = {
|
export const ISO_COUNTRIES = {
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,45 @@
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { getClientIp } from 'request-ip';
|
|
||||||
import { browserName, detectOS } from 'detect-browser';
|
import { browserName, detectOS } from 'detect-browser';
|
||||||
import isLocalhost from 'is-localhost-ip';
|
import isLocalhost from 'is-localhost-ip';
|
||||||
import ipaddr from 'ipaddr.js';
|
import ipaddr from 'ipaddr.js';
|
||||||
import maxmind from 'maxmind';
|
import maxmind from 'maxmind';
|
||||||
import { safeDecodeURIComponent } from 'next-basics';
|
|
||||||
import {
|
import {
|
||||||
DESKTOP_OS,
|
DESKTOP_OS,
|
||||||
MOBILE_OS,
|
MOBILE_OS,
|
||||||
DESKTOP_SCREEN_WIDTH,
|
DESKTOP_SCREEN_WIDTH,
|
||||||
LAPTOP_SCREEN_WIDTH,
|
LAPTOP_SCREEN_WIDTH,
|
||||||
MOBILE_SCREEN_WIDTH,
|
MOBILE_SCREEN_WIDTH,
|
||||||
|
IP_ADDRESS_HEADERS,
|
||||||
} from './constants';
|
} from './constants';
|
||||||
import { NextApiRequestCollect } from 'pages/api/send';
|
|
||||||
|
|
||||||
let lookup;
|
let lookup;
|
||||||
|
|
||||||
export function getIpAddress(req: NextApiRequestCollect) {
|
export function getIpAddress(headers: Headers) {
|
||||||
const customHeader = String(process.env.CLIENT_IP_HEADER).toLowerCase();
|
const customHeader = process.env.CLIENT_IP_HEADER;
|
||||||
|
|
||||||
// Custom header
|
if (customHeader && headers.get(customHeader)) {
|
||||||
if (customHeader !== 'undefined' && req.headers[customHeader]) {
|
return headers.get(customHeader);
|
||||||
return req.headers[customHeader];
|
|
||||||
}
|
|
||||||
// Cloudflare
|
|
||||||
else if (req.headers['cf-connecting-ip']) {
|
|
||||||
return req.headers['cf-connecting-ip'];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return getClientIp(req);
|
const header = IP_ADDRESS_HEADERS.find(name => {
|
||||||
|
return headers.get(name);
|
||||||
|
});
|
||||||
|
|
||||||
|
const ip = headers.get(header);
|
||||||
|
|
||||||
|
if (header === 'x-forwarded-for') {
|
||||||
|
return ip?.split(',')?.[0]?.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (header === 'forwarded') {
|
||||||
|
const match = ip.match(/for=(\[?[0-9a-fA-F:.]+\]?)/);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
return match[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ip;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDevice(screen: string, os: string) {
|
export function getDevice(screen: string, os: string) {
|
||||||
|
|
@ -67,7 +78,7 @@ function getRegionCode(country: string, region: string) {
|
||||||
return region.includes('-') ? region : `${country}-${region}`;
|
return region.includes('-') ? region : `${country}-${region}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function safeDecodeCfHeader(s: string | undefined | null): string | undefined | null {
|
function decodeHeader(s: string | undefined | null): string | undefined | null {
|
||||||
if (s === undefined || s === null) {
|
if (s === undefined || s === null) {
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
@ -75,36 +86,38 @@ function safeDecodeCfHeader(s: string | undefined | null): string | undefined |
|
||||||
return Buffer.from(s, 'latin1').toString('utf-8');
|
return Buffer.from(s, 'latin1').toString('utf-8');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getLocation(ip: string, req: NextApiRequestCollect) {
|
export async function getLocation(ip: string = '', headers: Headers) {
|
||||||
// Ignore local ips
|
// Ignore local ips
|
||||||
if (await isLocalhost(ip)) {
|
if (await isLocalhost(ip)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cloudflare headers
|
if (!process.env.SKIP_LOCATION_HEADERS) {
|
||||||
if (req.headers['cf-ipcountry']) {
|
// Cloudflare headers
|
||||||
const country = safeDecodeCfHeader(req.headers['cf-ipcountry']);
|
if (headers.get('cf-ipcountry')) {
|
||||||
const subdivision1 = safeDecodeCfHeader(req.headers['cf-region-code']);
|
const country = decodeHeader(headers.get('cf-ipcountry'));
|
||||||
const city = safeDecodeCfHeader(req.headers['cf-ipcity']);
|
const subdivision1 = decodeHeader(headers.get('cf-region-code'));
|
||||||
|
const city = decodeHeader(headers.get('cf-ipcity'));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
country,
|
country,
|
||||||
subdivision1: getRegionCode(country, subdivision1),
|
subdivision1: getRegionCode(country, subdivision1),
|
||||||
city,
|
city,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vercel headers
|
// Vercel headers
|
||||||
if (req.headers['x-vercel-ip-country']) {
|
if (headers.get('x-vercel-ip-country')) {
|
||||||
const country = safeDecodeURIComponent(req.headers['x-vercel-ip-country']);
|
const country = decodeHeader(headers.get('x-vercel-ip-country'));
|
||||||
const subdivision1 = safeDecodeURIComponent(req.headers['x-vercel-ip-country-region']);
|
const subdivision1 = decodeHeader(headers.get('x-vercel-ip-country-region'));
|
||||||
const city = safeDecodeURIComponent(req.headers['x-vercel-ip-city']);
|
const city = decodeHeader(headers.get('x-vercel-ip-city'));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
country,
|
country,
|
||||||
subdivision1: getRegionCode(country, subdivision1),
|
subdivision1: getRegionCode(country, subdivision1),
|
||||||
city,
|
city,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Database lookup
|
// Database lookup
|
||||||
|
|
@ -131,22 +144,22 @@ export async function getLocation(ip: string, req: NextApiRequestCollect) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getClientInfo(req: NextApiRequestCollect) {
|
export async function getClientInfo(request: Request, payload: Record<string, any>) {
|
||||||
const userAgent = req.body?.payload?.userAgent || req.headers['user-agent'];
|
const userAgent = payload?.userAgent || request.headers.get('user-agent');
|
||||||
const ip = req.body?.payload?.ip || getIpAddress(req);
|
const ip = payload?.ip || getIpAddress(request.headers);
|
||||||
const location = await getLocation(ip, req);
|
const location = await getLocation(ip, request.headers);
|
||||||
const country = location?.country;
|
const country = payload?.userAgent || location?.country;
|
||||||
const subdivision1 = location?.subdivision1;
|
const subdivision1 = location?.subdivision1;
|
||||||
const subdivision2 = location?.subdivision2;
|
const subdivision2 = location?.subdivision2;
|
||||||
const city = location?.city;
|
const city = location?.city;
|
||||||
const browser = browserName(userAgent);
|
const browser = browserName(userAgent);
|
||||||
const os = detectOS(userAgent) as string;
|
const os = detectOS(userAgent) as string;
|
||||||
const device = getDevice(req.body?.payload?.screen, os);
|
const device = getDevice(payload?.screen, os);
|
||||||
|
|
||||||
return { userAgent, browser, os, ip, country, subdivision1, subdivision2, city, device };
|
return { userAgent, browser, os, ip, country, subdivision1, subdivision2, city, device };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasBlockedIp(req: NextApiRequestCollect) {
|
export function hasBlockedIp(clientIp: string) {
|
||||||
const ignoreIps = process.env.IGNORE_IP;
|
const ignoreIps = process.env.IGNORE_IP;
|
||||||
|
|
||||||
if (ignoreIps) {
|
if (ignoreIps) {
|
||||||
|
|
@ -156,17 +169,19 @@ export function hasBlockedIp(req: NextApiRequestCollect) {
|
||||||
ips.push(...ignoreIps.split(',').map(n => n.trim()));
|
ips.push(...ignoreIps.split(',').map(n => n.trim()));
|
||||||
}
|
}
|
||||||
|
|
||||||
const clientIp = getIpAddress(req);
|
|
||||||
|
|
||||||
return ips.find(ip => {
|
return ips.find(ip => {
|
||||||
if (ip === clientIp) return true;
|
if (ip === clientIp) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// CIDR notation
|
// CIDR notation
|
||||||
if (ip.indexOf('/') > 0) {
|
if (ip.indexOf('/') > 0) {
|
||||||
const addr = ipaddr.parse(clientIp);
|
const addr = ipaddr.parse(clientIp);
|
||||||
const range = ipaddr.parseCIDR(ip);
|
const range = ipaddr.parseCIDR(ip);
|
||||||
|
|
||||||
if (addr.kind() === range[0].kind() && addr.match(range)) return true;
|
if (addr.kind() === range[0].kind() && addr.match(range)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,20 @@
|
||||||
import { serializeError } from 'serialize-error';
|
|
||||||
import { getWebsiteSession, getWebsite } from 'queries';
|
|
||||||
import { Website, Session } from '@prisma/client';
|
import { Website, Session } from '@prisma/client';
|
||||||
import { getClient, redisEnabled } from '@umami/redis-client';
|
import { getClient, redisEnabled } from '@umami/redis-client';
|
||||||
|
import { getWebsiteSession, getWebsite } from 'queries';
|
||||||
|
|
||||||
export async function fetchWebsite(websiteId: string): Promise<Website> {
|
export async function fetchWebsite(websiteId: string): Promise<Website> {
|
||||||
let website = null;
|
let website = null;
|
||||||
|
|
||||||
try {
|
if (redisEnabled) {
|
||||||
if (redisEnabled) {
|
const redis = getClient();
|
||||||
const redis = getClient();
|
|
||||||
|
|
||||||
website = await redis.fetch(`website:${websiteId}`, () => getWebsite(websiteId), 86400);
|
website = await redis.fetch(`website:${websiteId}`, () => getWebsite(websiteId), 86400);
|
||||||
} else {
|
} else {
|
||||||
website = await getWebsite(websiteId);
|
website = await getWebsite(websiteId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!website || website.deletedAt) {
|
if (!website || website.deletedAt) {
|
||||||
return null;
|
return null;
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log('FETCH WEBSITE ERROR:', serializeError(e));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return website;
|
return website;
|
||||||
|
|
@ -29,21 +23,16 @@ export async function fetchWebsite(websiteId: string): Promise<Website> {
|
||||||
export async function fetchSession(websiteId: string, sessionId: string): Promise<Session> {
|
export async function fetchSession(websiteId: string, sessionId: string): Promise<Session> {
|
||||||
let session = null;
|
let session = null;
|
||||||
|
|
||||||
try {
|
if (redisEnabled) {
|
||||||
if (redisEnabled) {
|
const redis = getClient();
|
||||||
const redis = getClient();
|
|
||||||
|
|
||||||
session = await redis.fetch(
|
session = await redis.fetch(
|
||||||
`session:${sessionId}`,
|
`session:${sessionId}`,
|
||||||
() => getWebsiteSession(websiteId, sessionId),
|
() => getWebsiteSession(websiteId, sessionId),
|
||||||
86400,
|
86400,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
session = await getWebsiteSession(websiteId, sessionId);
|
session = await getWebsiteSession(websiteId, sessionId);
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log('FETCH SESSION ERROR:', serializeError(e));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
|
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
import cors from 'cors';
|
|
||||||
import debug from 'debug';
|
|
||||||
import { getClient, redisEnabled } from '@umami/redis-client';
|
|
||||||
import { getAuthToken, parseShareToken } from 'lib/auth';
|
|
||||||
import { ROLES } from 'lib/constants';
|
|
||||||
import { secret } from 'lib/crypto';
|
|
||||||
import { getSession } from 'lib/session';
|
|
||||||
import {
|
|
||||||
badRequest,
|
|
||||||
createMiddleware,
|
|
||||||
notFound,
|
|
||||||
parseSecureToken,
|
|
||||||
unauthorized,
|
|
||||||
} from 'next-basics';
|
|
||||||
import { NextApiRequestCollect } from 'pages/api/send';
|
|
||||||
import { getUser } 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 getSession(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.startsWith('Website not found')) {
|
|
||||||
return notFound(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 getUser(userId);
|
|
||||||
} else if (redisEnabled && authKey) {
|
|
||||||
const redis = getClient();
|
|
||||||
|
|
||||||
const key = await redis.get(authKey);
|
|
||||||
|
|
||||||
if (key?.userId) {
|
|
||||||
user = await getUser(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);
|
|
||||||
};
|
|
||||||
|
|
@ -243,7 +243,7 @@ async function pagedQuery<T>(model: string, criteria: T, pageParams: PageParams)
|
||||||
const data = await prisma.client[model].findMany({
|
const data = await prisma.client[model].findMany({
|
||||||
...criteria,
|
...criteria,
|
||||||
...{
|
...{
|
||||||
...(size > 0 && { take: +size, skip: +size * (page - 1) }),
|
...(size > 0 && { take: +size, skip: +size * (+page - 1) }),
|
||||||
...(orderBy && {
|
...(orderBy && {
|
||||||
orderBy: [
|
orderBy: [
|
||||||
{
|
{
|
||||||
|
|
@ -266,7 +266,7 @@ async function pagedRawQuery(
|
||||||
) {
|
) {
|
||||||
const { page = 1, pageSize, orderBy, sortDescending = false } = pageParams;
|
const { page = 1, pageSize, orderBy, sortDescending = false } = pageParams;
|
||||||
const size = +pageSize || DEFAULT_PAGE_SIZE;
|
const size = +pageSize || DEFAULT_PAGE_SIZE;
|
||||||
const offset = +size * (page - 1);
|
const offset = +size * (+page - 1);
|
||||||
const direction = sortDescending ? 'desc' : 'asc';
|
const direction = sortDescending ? 'desc' : 'asc';
|
||||||
|
|
||||||
const statements = [
|
const statements = [
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,55 @@
|
||||||
import { NextApiRequest } from 'next';
|
import { ZodObject } from 'zod';
|
||||||
|
import { FILTER_COLUMNS } from 'lib/constants';
|
||||||
|
import { badRequest, unauthorized } from 'lib/response';
|
||||||
import { getAllowedUnits, getMinimumUnit } from './date';
|
import { getAllowedUnits, getMinimumUnit } from './date';
|
||||||
import { getWebsiteDateRange } from '../queries';
|
import { getWebsiteDateRange } from '../queries';
|
||||||
import { FILTER_COLUMNS } from 'lib/constants';
|
import { checkAuth } from 'lib/auth';
|
||||||
|
|
||||||
export async function getRequestDateRange(req: NextApiRequest) {
|
export async function getJsonBody(request: Request) {
|
||||||
const { websiteId, startAt, endAt, unit } = req.query;
|
try {
|
||||||
|
return await request.clone().json();
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseRequest(
|
||||||
|
request: Request,
|
||||||
|
schema?: ZodObject<any>,
|
||||||
|
options?: { skipAuth: boolean },
|
||||||
|
): Promise<any> {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
let query = Object.fromEntries(url.searchParams);
|
||||||
|
let body = await getJsonBody(request);
|
||||||
|
let error: () => void | undefined;
|
||||||
|
let auth = null;
|
||||||
|
|
||||||
|
if (schema) {
|
||||||
|
const isGet = request.method === 'GET';
|
||||||
|
const result = schema.safeParse(isGet ? query : body);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
error = () => badRequest(result.error);
|
||||||
|
} else if (isGet) {
|
||||||
|
query = result.data;
|
||||||
|
} else {
|
||||||
|
body = result.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options?.skipAuth && !error) {
|
||||||
|
auth = await checkAuth(request);
|
||||||
|
|
||||||
|
if (!auth) {
|
||||||
|
error = () => unauthorized();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { url, query, body, auth, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRequestDateRange(query: Record<string, any>) {
|
||||||
|
const { websiteId, startAt, endAt, unit } = query;
|
||||||
|
|
||||||
// All-time
|
// All-time
|
||||||
if (+startAt === 0 && +endAt === 1) {
|
if (+startAt === 0 && +endAt === 1) {
|
||||||
|
|
@ -31,9 +76,9 @@ export async function getRequestDateRange(req: NextApiRequest) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRequestFilters(req: NextApiRequest) {
|
export function getRequestFilters(query: Record<string, any>) {
|
||||||
return Object.keys(FILTER_COLUMNS).reduce((obj, key) => {
|
return Object.keys(FILTER_COLUMNS).reduce((obj, key) => {
|
||||||
const value = req.query[key];
|
const value = query[key];
|
||||||
|
|
||||||
if (value !== undefined) {
|
if (value !== undefined) {
|
||||||
obj[key] = value;
|
obj[key] = value;
|
||||||
|
|
|
||||||
29
src/lib/response.ts
Normal file
29
src/lib/response.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { serializeError } from 'serialize-error';
|
||||||
|
|
||||||
|
export function ok() {
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function json(data: any) {
|
||||||
|
return Response.json(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function badRequest(message?: any) {
|
||||||
|
return Response.json({ error: 'Bad request', message }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unauthorized(message?: any) {
|
||||||
|
return Response.json({ error: 'Unauthorized', message }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function forbidden(message?: any) {
|
||||||
|
return Response.json({ error: 'Forbidden', message }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notFound(message?: any) {
|
||||||
|
return Response.json({ error: 'Not found', message }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serverError(error?: any) {
|
||||||
|
return Response.json({ error: 'Server error', message: serializeError(error) }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,59 @@
|
||||||
import * as yup from 'yup';
|
import { z } from 'zod';
|
||||||
|
import { isValidTimezone } from 'lib/date';
|
||||||
|
import { UNIT_TYPES } from './constants';
|
||||||
|
|
||||||
export const dateRange = {
|
export const filterParams = {
|
||||||
startAt: yup.number().integer().required(),
|
url: z.string().optional(),
|
||||||
endAt: yup.number().integer().min(yup.ref('startAt')).required(),
|
referrer: z.string().optional(),
|
||||||
|
title: z.string().optional(),
|
||||||
|
query: z.string().optional(),
|
||||||
|
os: z.string().optional(),
|
||||||
|
browser: z.string().optional(),
|
||||||
|
device: z.string().optional(),
|
||||||
|
country: z.string().optional(),
|
||||||
|
region: z.string().optional(),
|
||||||
|
city: z.string().optional(),
|
||||||
|
tag: z.string().optional(),
|
||||||
|
host: z.string().optional(),
|
||||||
|
language: z.string().optional(),
|
||||||
|
event: z.string().optional(),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const pageInfo = {
|
export const pagingParams = {
|
||||||
query: yup.string(),
|
page: z.coerce.number().int().positive().optional(),
|
||||||
page: yup.number().integer().positive(),
|
pageSize: z.coerce.number().int().positive().optional(),
|
||||||
pageSize: yup.number().integer().positive().min(1).max(200),
|
orderBy: z.string().optional(),
|
||||||
orderBy: yup.string(),
|
query: z.string().optional(),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const timezoneParam = z.string().refine(value => isValidTimezone(value), {
|
||||||
|
message: 'Invalid timezone',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const unitParam = z.string().refine(value => UNIT_TYPES.includes(value), {
|
||||||
|
message: 'Invalid unit',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const roleParam = z.enum(['team-member', 'team-view-only', 'team-manager']);
|
||||||
|
|
||||||
|
export const reportTypeParam = z.enum([
|
||||||
|
'funnel',
|
||||||
|
'insights',
|
||||||
|
'retention',
|
||||||
|
'utm',
|
||||||
|
'goals',
|
||||||
|
'journey',
|
||||||
|
'revenue',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const reportParms = {
|
||||||
|
websiteId: z.string().uuid(),
|
||||||
|
dateRange: z.object({
|
||||||
|
startDate: z.coerce.date(),
|
||||||
|
endDate: z.coerce.date(),
|
||||||
|
num: z.coerce.number().optional(),
|
||||||
|
offset: z.coerce.number().optional(),
|
||||||
|
unit: z.string().optional(),
|
||||||
|
value: z.string().optional(),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
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 clickhouse from './clickhouse';
|
|
||||||
import { fetchSession, fetchWebsite } from './load';
|
|
||||||
import { SessionData } from 'lib/types';
|
|
||||||
|
|
||||||
export async function getSession(req: NextApiRequestCollect): Promise<SessionData> {
|
|
||||||
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());
|
|
||||||
|
|
||||||
// Token is valid
|
|
||||||
if (result) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify payload
|
|
||||||
const { website: websiteId, hostname, screen, language } = payload;
|
|
||||||
|
|
||||||
// Find website
|
|
||||||
const website = await fetchWebsite(websiteId);
|
|
||||||
|
|
||||||
if (!website) {
|
|
||||||
throw new Error(`Website not found: ${websiteId}.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { userAgent, browser, os, ip, country, subdivision1, subdivision2, city, device } =
|
|
||||||
await getClientInfo(req);
|
|
||||||
|
|
||||||
const sessionId = uuid(websiteId, hostname, ip, userAgent);
|
|
||||||
const visitId = uuid(sessionId, visitSalt());
|
|
||||||
|
|
||||||
// Clickhouse does not require session lookup
|
|
||||||
if (clickhouse.enabled) {
|
|
||||||
return {
|
|
||||||
id: sessionId,
|
|
||||||
websiteId,
|
|
||||||
visitId,
|
|
||||||
hostname,
|
|
||||||
browser,
|
|
||||||
os,
|
|
||||||
device,
|
|
||||||
screen,
|
|
||||||
language,
|
|
||||||
country,
|
|
||||||
subdivision1,
|
|
||||||
subdivision2,
|
|
||||||
city,
|
|
||||||
ip,
|
|
||||||
userAgent,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find session
|
|
||||||
let session = await fetchSession(websiteId, 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, visitId };
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { NextApiRequest } from 'next';
|
|
||||||
import {
|
import {
|
||||||
COLLECTION_TYPE,
|
COLLECTION_TYPE,
|
||||||
DATA_TYPE,
|
DATA_TYPE,
|
||||||
|
|
@ -8,7 +7,6 @@ import {
|
||||||
REPORT_TYPES,
|
REPORT_TYPES,
|
||||||
ROLES,
|
ROLES,
|
||||||
} from './constants';
|
} from './constants';
|
||||||
import * as yup from 'yup';
|
|
||||||
import { TIME_UNIT } from './date';
|
import { TIME_UNIT } from './date';
|
||||||
import { Dispatch, SetStateAction } from 'react';
|
import { Dispatch, SetStateAction } from 'react';
|
||||||
|
|
||||||
|
|
@ -25,9 +23,9 @@ export type KafkaTopic = ObjectValues<typeof KAFKA_TOPIC>;
|
||||||
export type ReportType = ObjectValues<typeof REPORT_TYPES>;
|
export type ReportType = ObjectValues<typeof REPORT_TYPES>;
|
||||||
|
|
||||||
export interface PageParams {
|
export interface PageParams {
|
||||||
query?: string;
|
search?: string;
|
||||||
page?: number;
|
page?: string;
|
||||||
pageSize?: number;
|
pageSize?: string;
|
||||||
orderBy?: string;
|
orderBy?: string;
|
||||||
sortDescending?: boolean;
|
sortDescending?: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -65,26 +63,6 @@ export interface Auth {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
import * as yup from 'yup';
|
|
||||||
import { isValidTimezone } from 'lib/date';
|
|
||||||
import { UNIT_TYPES } from './constants';
|
|
||||||
|
|
||||||
export const TimezoneTest = yup
|
|
||||||
.string()
|
|
||||||
.default('UTC')
|
|
||||||
.test(
|
|
||||||
'timezone',
|
|
||||||
() => `Invalid timezone`,
|
|
||||||
value => isValidTimezone(value),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const UnitTypeTest = yup.string().test(
|
|
||||||
'unit',
|
|
||||||
() => `Invalid unit`,
|
|
||||||
value => UNIT_TYPES.includes(value),
|
|
||||||
);
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
import { canViewUsers } from 'lib/auth';
|
|
||||||
import { useAuth, useValidate } from 'lib/middleware';
|
|
||||||
import { NextApiRequestQueryBody, Role, PageParams, User } from 'lib/types';
|
|
||||||
import { pageInfo } from 'lib/schema';
|
|
||||||
import { NextApiResponse } from 'next';
|
|
||||||
import { methodNotAllowed, ok, unauthorized } from 'next-basics';
|
|
||||||
import { getUsers } from 'queries';
|
|
||||||
import * as yup from 'yup';
|
|
||||||
|
|
||||||
export interface UsersRequestQuery extends PageParams {}
|
|
||||||
export interface UsersRequestBody {
|
|
||||||
userId: string;
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
role: Role;
|
|
||||||
}
|
|
||||||
|
|
||||||
const schema = {
|
|
||||||
GET: yup.object().shape({
|
|
||||||
...pageInfo,
|
|
||||||
}),
|
|
||||||
POST: yup.object().shape({
|
|
||||||
userId: yup.string().uuid(),
|
|
||||||
username: yup.string().max(255).required(),
|
|
||||||
password: yup.string().required(),
|
|
||||||
role: yup
|
|
||||||
.string()
|
|
||||||
.matches(/admin|user|view-only/i)
|
|
||||||
.required(),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async (
|
|
||||||
req: NextApiRequestQueryBody<UsersRequestQuery, UsersRequestBody>,
|
|
||||||
res: NextApiResponse<User[] | User>,
|
|
||||||
) => {
|
|
||||||
await useAuth(req, res);
|
|
||||||
await useValidate(schema, req, res);
|
|
||||||
|
|
||||||
if (req.method === 'GET') {
|
|
||||||
if (!(await canViewUsers(req.auth))) {
|
|
||||||
return unauthorized(res);
|
|
||||||
}
|
|
||||||
|
|
||||||
const users = await getUsers(
|
|
||||||
{
|
|
||||||
include: {
|
|
||||||
_count: {
|
|
||||||
select: {
|
|
||||||
websiteUser: {
|
|
||||||
where: { deletedAt: null },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
req.query,
|
|
||||||
);
|
|
||||||
|
|
||||||
return ok(res, users);
|
|
||||||
}
|
|
||||||
|
|
||||||
return methodNotAllowed(res);
|
|
||||||
};
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
import { canViewAllWebsites } from 'lib/auth';
|
|
||||||
import { ROLES } from 'lib/constants';
|
|
||||||
import { useAuth, useCors, useValidate } from 'lib/middleware';
|
|
||||||
import { pageInfo } from 'lib/schema';
|
|
||||||
import { NextApiRequestQueryBody, PageParams } from 'lib/types';
|
|
||||||
import { NextApiResponse } from 'next';
|
|
||||||
import { methodNotAllowed, ok, unauthorized } from 'next-basics';
|
|
||||||
import { getWebsites } from 'queries';
|
|
||||||
import * as yup from 'yup';
|
|
||||||
|
|
||||||
export interface WebsitesRequestQuery extends PageParams {
|
|
||||||
userId?: string;
|
|
||||||
includeOwnedTeams?: boolean;
|
|
||||||
includeAllTeams?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WebsitesRequestBody {
|
|
||||||
name: string;
|
|
||||||
domain: string;
|
|
||||||
shareId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const schema = {
|
|
||||||
GET: yup.object().shape({
|
|
||||||
...pageInfo,
|
|
||||||
}),
|
|
||||||
POST: yup.object().shape({
|
|
||||||
name: yup.string().max(100).required(),
|
|
||||||
domain: yup.string().max(500).required(),
|
|
||||||
shareId: yup.string().max(50).nullable(),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async (
|
|
||||||
req: NextApiRequestQueryBody<WebsitesRequestQuery, WebsitesRequestBody>,
|
|
||||||
res: NextApiResponse,
|
|
||||||
) => {
|
|
||||||
await useCors(req, res);
|
|
||||||
await useAuth(req, res);
|
|
||||||
await useValidate(schema, req, res);
|
|
||||||
|
|
||||||
if (req.method === 'GET') {
|
|
||||||
if (!(await canViewAllWebsites(req.auth))) {
|
|
||||||
return unauthorized(res);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { userId, includeOwnedTeams, includeAllTeams } = req.query;
|
|
||||||
|
|
||||||
const websites = await getWebsites(
|
|
||||||
{
|
|
||||||
where: {
|
|
||||||
OR: [
|
|
||||||
...(userId && [{ userId }]),
|
|
||||||
...(userId && includeOwnedTeams
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
team: {
|
|
||||||
deletedAt: null,
|
|
||||||
teamUser: {
|
|
||||||
some: {
|
|
||||||
role: ROLES.teamOwner,
|
|
||||||
userId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
...(userId && includeAllTeams
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
team: {
|
|
||||||
deletedAt: null,
|
|
||||||
teamUser: {
|
|
||||||
some: {
|
|
||||||
userId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
user: {
|
|
||||||
select: {
|
|
||||||
username: true,
|
|
||||||
id: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
team: {
|
|
||||||
where: {
|
|
||||||
deletedAt: null,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
teamUser: {
|
|
||||||
where: {
|
|
||||||
role: ROLES.teamOwner,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
req.query,
|
|
||||||
);
|
|
||||||
|
|
||||||
return ok(res, websites);
|
|
||||||
}
|
|
||||||
|
|
||||||
return methodNotAllowed(res);
|
|
||||||
};
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
import { redisEnabled } from '@umami/redis-client';
|
|
||||||
import { saveAuth } from 'lib/auth';
|
|
||||||
import { secret } from 'lib/crypto';
|
|
||||||
import { useValidate } from 'lib/middleware';
|
|
||||||
import { NextApiRequestQueryBody, User } from 'lib/types';
|
|
||||||
import { NextApiResponse } from 'next';
|
|
||||||
import {
|
|
||||||
checkPassword,
|
|
||||||
createSecureToken,
|
|
||||||
forbidden,
|
|
||||||
methodNotAllowed,
|
|
||||||
ok,
|
|
||||||
unauthorized,
|
|
||||||
} from 'next-basics';
|
|
||||||
import { getUserByUsername } from 'queries';
|
|
||||||
import * as yup from 'yup';
|
|
||||||
import { ROLES } from 'lib/constants';
|
|
||||||
|
|
||||||
export interface LoginRequestBody {
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LoginResponse {
|
|
||||||
token: string;
|
|
||||||
user: User;
|
|
||||||
}
|
|
||||||
|
|
||||||
const schema = {
|
|
||||||
POST: yup.object().shape({
|
|
||||||
username: yup.string().required(),
|
|
||||||
password: yup.string().required(),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async (
|
|
||||||
req: NextApiRequestQueryBody<any, LoginRequestBody>,
|
|
||||||
res: NextApiResponse<LoginResponse>,
|
|
||||||
) => {
|
|
||||||
if (process.env.disableLogin) {
|
|
||||||
return forbidden(res);
|
|
||||||
}
|
|
||||||
|
|
||||||
await useValidate(schema, req, res);
|
|
||||||
|
|
||||||
if (req.method === 'POST') {
|
|
||||||
const { username, password } = req.body;
|
|
||||||
|
|
||||||
const user = await getUserByUsername(username, { includePassword: true });
|
|
||||||
|
|
||||||
if (user && checkPassword(password, user.password)) {
|
|
||||||
if (redisEnabled) {
|
|
||||||
const token = await saveAuth({ userId: user.id });
|
|
||||||
|
|
||||||
return ok(res, { token, user });
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = createSecureToken({ userId: user.id }, secret());
|
|
||||||
const { id, username, role, createdAt } = user;
|
|
||||||
|
|
||||||
return ok(res, {
|
|
||||||
token,
|
|
||||||
user: { id, username, role, createdAt, isAdmin: role === ROLES.admin },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return unauthorized(res, 'message.incorrect-username-password');
|
|
||||||
}
|
|
||||||
|
|
||||||
return methodNotAllowed(res);
|
|
||||||
};
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
import { methodNotAllowed, ok } from 'next-basics';
|
|
||||||
import { getClient, redisEnabled } from '@umami/redis-client';
|
|
||||||
import { useAuth } from 'lib/middleware';
|
|
||||||
import { getAuthToken } from 'lib/auth';
|
|
||||||
import { NextApiRequest, NextApiResponse } from 'next';
|
|
||||||
|
|
||||||
export default async (req: NextApiRequest, res: NextApiResponse) => {
|
|
||||||
await useAuth(req, res);
|
|
||||||
|
|
||||||
if (req.method === 'POST') {
|
|
||||||
if (redisEnabled) {
|
|
||||||
const redis = getClient();
|
|
||||||
|
|
||||||
await redis.del(getAuthToken(req));
|
|
||||||
}
|
|
||||||
|
|
||||||
return ok(res);
|
|
||||||
}
|
|
||||||
|
|
||||||
return methodNotAllowed(res);
|
|
||||||
};
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
import { NextApiRequestAuth } from 'lib/types';
|
|
||||||
import { useAuth } from 'lib/middleware';
|
|
||||||
import { NextApiResponse } from 'next';
|
|
||||||
import { badRequest, ok } from 'next-basics';
|
|
||||||
import { redisEnabled } from '@umami/redis-client';
|
|
||||||
import { saveAuth } from 'lib/auth';
|
|
||||||
|
|
||||||
export default async (req: NextApiRequestAuth, res: NextApiResponse) => {
|
|
||||||
await useAuth(req, res);
|
|
||||||
|
|
||||||
if (redisEnabled && req.auth.user) {
|
|
||||||
const token = await saveAuth({ userId: req.auth.user.id }, 86400);
|
|
||||||
|
|
||||||
return ok(res, { user: req.auth.user, token });
|
|
||||||
}
|
|
||||||
|
|
||||||
return badRequest(res);
|
|
||||||
};
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
import { NextApiRequestAuth } from 'lib/types';
|
|
||||||
import { useAuth } from 'lib/middleware';
|
|
||||||
import { NextApiResponse } from 'next';
|
|
||||||
import { ok } from 'next-basics';
|
|
||||||
|
|
||||||
export default async (req: NextApiRequestAuth, res: NextApiResponse) => {
|
|
||||||
await useAuth(req, res);
|
|
||||||
|
|
||||||
const { user } = req.auth;
|
|
||||||
|
|
||||||
return ok(res, user);
|
|
||||||
};
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
import { NextApiRequest, NextApiResponse } from 'next';
|
|
||||||
import { ok, methodNotAllowed } from 'next-basics';
|
|
||||||
|
|
||||||
export interface ConfigResponse {
|
|
||||||
telemetryDisabled: boolean;
|
|
||||||
trackerScriptName: string;
|
|
||||||
uiDisabled: boolean;
|
|
||||||
updatesDisabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async (req: NextApiRequest, res: NextApiResponse<ConfigResponse>) => {
|
|
||||||
if (req.method === 'GET') {
|
|
||||||
return ok(res, {
|
|
||||||
telemetryDisabled: !!process.env.DISABLE_TELEMETRY,
|
|
||||||
trackerScriptName: process.env.TRACKER_SCRIPT_NAME,
|
|
||||||
uiDisabled: !!process.env.DISABLE_UI,
|
|
||||||
updatesDisabled: !!process.env.DISABLE_UPDATES,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return methodNotAllowed(res);
|
|
||||||
};
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
import { NextApiRequest, NextApiResponse } from 'next';
|
|
||||||
import { ok } from 'next-basics';
|
|
||||||
|
|
||||||
export default async (req: NextApiRequest, res: NextApiResponse) => {
|
|
||||||
return ok(res);
|
|
||||||
};
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
import { NextApiResponse } from 'next';
|
|
||||||
import { useAuth } from 'lib/middleware';
|
|
||||||
import { NextApiRequestQueryBody, User } from 'lib/types';
|
|
||||||
import { ok } from 'next-basics';
|
|
||||||
|
|
||||||
export default async (
|
|
||||||
req: NextApiRequestQueryBody<unknown, unknown>,
|
|
||||||
res: NextApiResponse<User>,
|
|
||||||
) => {
|
|
||||||
await useAuth(req, res);
|
|
||||||
|
|
||||||
return ok(res, req.auth.user);
|
|
||||||
};
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
import { useAuth, useValidate } from 'lib/middleware';
|
|
||||||
import { NextApiRequestQueryBody, User } from 'lib/types';
|
|
||||||
import { NextApiResponse } from 'next';
|
|
||||||
import {
|
|
||||||
badRequest,
|
|
||||||
checkPassword,
|
|
||||||
forbidden,
|
|
||||||
hashPassword,
|
|
||||||
methodNotAllowed,
|
|
||||||
ok,
|
|
||||||
} from 'next-basics';
|
|
||||||
import { getUser, updateUser } from 'queries';
|
|
||||||
import * as yup from 'yup';
|
|
||||||
|
|
||||||
export interface UserPasswordRequestBody {
|
|
||||||
currentPassword: string;
|
|
||||||
newPassword: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const schema = {
|
|
||||||
POST: yup.object().shape({
|
|
||||||
currentPassword: yup.string().required(),
|
|
||||||
newPassword: yup.string().min(8).required(),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async (
|
|
||||||
req: NextApiRequestQueryBody<any, UserPasswordRequestBody>,
|
|
||||||
res: NextApiResponse<User>,
|
|
||||||
) => {
|
|
||||||
if (process.env.CLOUD_MODE) {
|
|
||||||
return forbidden(res);
|
|
||||||
}
|
|
||||||
|
|
||||||
await useAuth(req, res);
|
|
||||||
await useValidate(schema, req, res);
|
|
||||||
|
|
||||||
const { currentPassword, newPassword } = req.body;
|
|
||||||
const { id: userId } = req.auth.user;
|
|
||||||
|
|
||||||
if (req.method === 'POST') {
|
|
||||||
const user = await getUser(userId, { includePassword: true });
|
|
||||||
|
|
||||||
if (!checkPassword(currentPassword, user.password)) {
|
|
||||||
return badRequest(res, 'Current password is incorrect');
|
|
||||||
}
|
|
||||||
|
|
||||||
const password = hashPassword(newPassword);
|
|
||||||
|
|
||||||
const updated = await updateUser(userId, { password });
|
|
||||||
|
|
||||||
return ok(res, updated);
|
|
||||||
}
|
|
||||||
|
|
||||||
return methodNotAllowed(res);
|
|
||||||
};
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue