Initial commit

Created from https://vercel.com/new
This commit is contained in:
WaylonWalker 2021-12-08 18:25:42 +00:00
commit 4047a7ec23
378 changed files with 29334 additions and 0 deletions

15
pages/404.js Normal file
View file

@ -0,0 +1,15 @@
import React from 'react';
import Layout from 'components/layout/Layout';
import { FormattedMessage } from 'react-intl';
export default function Custom404() {
return (
<Layout>
<div className="row justify-content-center">
<h1>
<FormattedMessage id="message.page-not-found" defaultMessage="Page not found" />
</h1>
</div>
</Layout>
);
}

51
pages/_app.js Normal file
View file

@ -0,0 +1,51 @@
import React from 'react';
import Head from 'next/head';
import { useRouter } from 'next/router';
import { IntlProvider } from 'react-intl';
import { Provider } from 'react-redux';
import { useStore } from 'redux/store';
import useLocale from 'hooks/useLocale';
import useForceSSL from 'hooks/useForceSSL';
import 'styles/variables.css';
import 'styles/bootstrap-grid.css';
import 'styles/index.css';
import '@fontsource/inter/400.css';
import '@fontsource/inter/600.css';
const Intl = ({ children }) => {
const { locale, messages } = useLocale();
const Wrapper = ({ children }) => <span className={locale}>{children}</span>;
return (
<IntlProvider locale={locale} messages={messages[locale]} textComponent={Wrapper}>
{children}
</IntlProvider>
);
};
export default function App({ Component, pageProps }) {
useForceSSL(process.env.FORCE_SSL);
const store = useStore();
const { basePath } = useRouter();
return (
<Provider store={store}>
<Head>
<link rel="icon" href={`${basePath}/favicon.ico`} />
<link rel="apple-touch-icon" sizes="180x180" href={`${basePath}/apple-touch-icon.png`} />
<link rel="icon" type="image/png" sizes="32x32" href={`${basePath}/favicon-32x32.png`} />
<link rel="icon" type="image/png" sizes="16x16" href={`${basePath}/favicon-16x16.png`} />
<link rel="manifest" href={`${basePath}/site.webmanifest`} />
<link rel="mask-icon" href={`${basePath}/safari-pinned-tab.svg`} color="#5bbad5" />
<meta name="msapplication-TileColor" content="#da532c" />
<meta name="theme-color" content="#fafafa" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#2f2f2f" media="(prefers-color-scheme: dark)" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</Head>
<Intl>
<Component {...pageProps} />
</Intl>
</Provider>
);
}

29
pages/api/account/[id].js Normal file
View file

@ -0,0 +1,29 @@
import { getAccountById, deleteAccount } from 'lib/queries';
import { useAuth } from 'lib/middleware';
import { methodNotAllowed, ok, unauthorized } from 'lib/response';
export default async (req, res) => {
await useAuth(req, res);
const { is_admin } = req.auth;
const { id } = req.query;
const user_id = +id;
if (!is_admin) {
return unauthorized(res);
}
if (req.method === 'GET') {
const account = await getAccountById(user_id);
return ok(res, account);
}
if (req.method === 'DELETE') {
await deleteAccount(user_id);
return ok(res);
}
return methodNotAllowed(res);
};

View file

@ -0,0 +1,61 @@
import { getAccountById, getAccountByUsername, updateAccount, createAccount } from 'lib/queries';
import { useAuth } from 'lib/middleware';
import { hashPassword } from 'lib/crypto';
import { ok, unauthorized, methodNotAllowed, badRequest } from 'lib/response';
export default async (req, res) => {
await useAuth(req, res);
const { user_id: current_user_id, is_admin: current_user_is_admin } = req.auth;
if (req.method === 'POST') {
const { user_id, username, password, is_admin } = req.body;
if (user_id) {
const account = await getAccountById(user_id);
if (account.user_id === current_user_id || current_user_is_admin) {
const data = {};
if (password) {
data.password = hashPassword(password);
}
// Only admin can change these fields
if (current_user_is_admin) {
// Cannot change username of admin
if (username !== 'admin') {
data.username = username;
}
data.is_admin = is_admin;
}
if (data.username && account.username !== data.username) {
const accountByUsername = await getAccountByUsername(username);
if (accountByUsername) {
return badRequest(res, 'Account already exists');
}
}
const updated = await updateAccount(user_id, data);
return ok(res, updated);
}
return unauthorized(res);
} else {
const accountByUsername = await getAccountByUsername(username);
if (accountByUsername) {
return badRequest(res, 'Account already exists');
}
const created = await createAccount({ username, password: hashPassword(password) });
return ok(res, created);
}
}
return methodNotAllowed(res);
};

View file

@ -0,0 +1,32 @@
import { getAccountById, updateAccount } from 'lib/queries';
import { useAuth } from 'lib/middleware';
import { badRequest, methodNotAllowed, ok, unauthorized } from 'lib/response';
import { checkPassword, hashPassword } from 'lib/crypto';
export default async (req, res) => {
await useAuth(req, res);
const { user_id: auth_user_id, is_admin } = req.auth;
const { user_id, current_password, new_password } = req.body;
if (!is_admin && user_id !== auth_user_id) {
return unauthorized(res);
}
if (req.method === 'POST') {
const account = await getAccountById(user_id);
const valid = checkPassword(current_password, account.password);
if (!valid) {
return badRequest(res, 'Current password is incorrect');
}
const password = hashPassword(new_password);
const updated = await updateAccount(user_id, { password });
return ok(res, updated);
}
return methodNotAllowed(res);
};

21
pages/api/accounts.js Normal file
View file

@ -0,0 +1,21 @@
import { getAccounts } from 'lib/queries';
import { useAuth } from 'lib/middleware';
import { ok, unauthorized, methodNotAllowed } from 'lib/response';
export default async (req, res) => {
await useAuth(req, res);
const { is_admin } = req.auth;
if (!is_admin) {
return unauthorized(res);
}
if (req.method === 'GET') {
const accounts = await getAccounts();
return ok(res, accounts);
}
return methodNotAllowed(res);
};

32
pages/api/auth/login.js Normal file
View file

@ -0,0 +1,32 @@
import { serialize } from 'cookie';
import { checkPassword, createSecureToken } from 'lib/crypto';
import { getAccountByUsername } from 'lib/queries';
import { AUTH_COOKIE_NAME } from 'lib/constants';
import { ok, unauthorized, badRequest } from 'lib/response';
export default async (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return badRequest(res);
}
const account = await getAccountByUsername(username);
if (account && (await checkPassword(password, account.password))) {
const { user_id, username, is_admin } = account;
const token = await createSecureToken({ user_id, username, is_admin });
const cookie = serialize(AUTH_COOKIE_NAME, token, {
path: '/',
httpOnly: true,
sameSite: true,
maxAge: 60 * 60 * 24 * 365,
});
res.setHeader('Set-Cookie', [cookie]);
return ok(res, { token });
}
return unauthorized(res);
};

15
pages/api/auth/logout.js Normal file
View file

@ -0,0 +1,15 @@
import { serialize } from 'cookie';
import { AUTH_COOKIE_NAME } from 'lib/constants';
import { ok } from 'lib/response';
export default async (req, res) => {
const cookie = serialize(AUTH_COOKIE_NAME, '', {
path: '/',
httpOnly: true,
maxAge: 0,
});
res.setHeader('Set-Cookie', [cookie]);
return ok(res);
};

12
pages/api/auth/verify.js Normal file
View file

@ -0,0 +1,12 @@
import { useAuth } from 'lib/middleware';
import { ok, unauthorized } from 'lib/response';
export default async (req, res) => {
await useAuth(req, res);
if (req.auth) {
return ok(res, req.auth);
}
return unauthorized(res);
};

60
pages/api/collect.js Normal file
View file

@ -0,0 +1,60 @@
import isbot from 'isbot';
import ipaddr from 'ipaddr.js';
import { savePageView, saveEvent } from 'lib/queries';
import { useCors, useSession } from 'lib/middleware';
import { getIpAddress } from 'lib/request';
import { ok, badRequest } from 'lib/response';
import { createToken } from 'lib/crypto';
export default async (req, res) => {
await useCors(req, res);
if (isbot(req.headers['user-agent'])) {
return ok(res);
}
if (process.env.IGNORE_IP) {
const ips = process.env.IGNORE_IP.split(',').map(n => n.trim());
const ip = getIpAddress(req);
const blocked = ips.find(i => {
if (i === ip) return true;
// CIDR notation
if (i.indexOf('/') > 0) {
const addr = ipaddr.parse(ip);
const range = ipaddr.parseCIDR(i);
if (addr.kind() === range[0].kind() && addr.match(range)) return true;
}
return false;
});
if (blocked) {
return ok(res);
}
}
await useSession(req, res);
const { type, payload } = req.body;
const {
session: { website_id, session_id },
} = req;
if (type === 'pageview') {
const { url, referrer } = payload;
await savePageView(website_id, session_id, url, referrer);
} else if (type === 'event') {
const { url, event_type, event_value } = payload;
await saveEvent(website_id, session_id, url, event_type, event_value);
} else {
return badRequest(res);
}
const token = await createToken({ website_id, session_id });
return ok(res, token);
};

View file

@ -0,0 +1,26 @@
import { subMinutes } from 'date-fns';
import { useAuth } from 'lib/middleware';
import { ok, methodNotAllowed } from 'lib/response';
import { getUserWebsites, getRealtimeData } from 'lib/queries';
import { createToken } from 'lib/crypto';
export default async (req, res) => {
await useAuth(req, res);
if (req.method === 'GET') {
const { user_id } = req.auth;
const websites = await getUserWebsites(user_id);
const ids = websites.map(({ website_id }) => website_id);
const token = await createToken({ websites: ids });
const data = await getRealtimeData(ids, subMinutes(new Date(), 30));
return ok(res, {
websites,
token,
data,
});
}
return methodNotAllowed(res);
};

View file

@ -0,0 +1,27 @@
import { useAuth } from 'lib/middleware';
import { ok, methodNotAllowed, badRequest } from 'lib/response';
import { getRealtimeData } from 'lib/queries';
import { parseToken } from 'lib/crypto';
import { TOKEN_HEADER } from 'lib/constants';
export default async (req, res) => {
await useAuth(req, res);
if (req.method === 'GET') {
const { start_at } = req.query;
const token = req.headers[TOKEN_HEADER];
if (!token) {
return badRequest(res);
}
const { websites } = await parseToken(token);
const data = await getRealtimeData(websites, new Date(+start_at));
return ok(res, data);
}
return methodNotAllowed(res);
};

22
pages/api/share/[id].js Normal file
View file

@ -0,0 +1,22 @@
import { getWebsiteByShareId } from 'lib/queries';
import { ok, notFound, methodNotAllowed } from 'lib/response';
import { createToken } from 'lib/crypto';
export default async (req, res) => {
const { id } = req.query;
if (req.method === 'GET') {
const website = await getWebsiteByShareId(id);
if (website) {
const websiteId = website.website_id;
const token = await createToken({ website_id: websiteId });
return ok(res, { websiteId, token });
}
return notFound(res);
}
return methodNotAllowed(res);
};

View file

@ -0,0 +1,21 @@
import { getActiveVisitors } from 'lib/queries';
import { methodNotAllowed, ok, unauthorized } from 'lib/response';
import { allowQuery } from 'lib/auth';
export default async (req, res) => {
if (req.method === 'GET') {
if (!(await allowQuery(req))) {
return unauthorized(res);
}
const { id } = req.query;
const websiteId = +id;
const result = await getActiveVisitors(websiteId);
return ok(res, result);
}
return methodNotAllowed(res);
};

View file

@ -0,0 +1,33 @@
import moment from 'moment-timezone';
import { getEventMetrics } from 'lib/queries';
import { ok, badRequest, methodNotAllowed, unauthorized } from 'lib/response';
import { allowQuery } from 'lib/auth';
const unitTypes = ['year', 'month', 'hour', 'day'];
export default async (req, res) => {
if (req.method === 'GET') {
if (!(await allowQuery(req))) {
return unauthorized(res);
}
const { id, start_at, end_at, unit, tz, url, event_type } = req.query;
if (!moment.tz.zone(tz) || !unitTypes.includes(unit)) {
return badRequest(res);
}
const websiteId = +id;
const startDate = new Date(+start_at);
const endDate = new Date(+end_at);
const events = await getEventMetrics(websiteId, startDate, endDate, tz, unit, {
url,
event_type,
});
return ok(res, events);
}
return methodNotAllowed(res);
};

View file

@ -0,0 +1,31 @@
import { deleteWebsite, getWebsiteById } from 'lib/queries';
import { methodNotAllowed, ok, unauthorized } from 'lib/response';
import { allowQuery } from 'lib/auth';
export default async (req, res) => {
const { id } = req.query;
const websiteId = +id;
if (req.method === 'GET') {
if (!(await allowQuery(req))) {
return unauthorized(res);
}
const website = await getWebsiteById(websiteId);
return ok(res, website);
}
if (req.method === 'DELETE') {
if (!(await allowQuery(req, true))) {
return unauthorized(res);
}
await deleteWebsite(websiteId);
return ok(res);
}
return methodNotAllowed(res);
};

View file

@ -0,0 +1,74 @@
import { getPageviewMetrics, getSessionMetrics, getWebsiteById } from 'lib/queries';
import { ok, methodNotAllowed, unauthorized, badRequest } from 'lib/response';
import { allowQuery } from 'lib/auth';
const sessionColumns = ['browser', 'os', 'device', 'country'];
const pageviewColumns = ['url', 'referrer'];
function getTable(type) {
if (type === 'event') {
return 'event';
}
if (sessionColumns.includes(type)) {
return 'session';
}
return 'pageview';
}
function getColumn(type) {
if (type === 'event') {
return `concat(event_type, '\t', event_value)`;
}
return type;
}
export default async (req, res) => {
if (req.method === 'GET') {
if (!(await allowQuery(req))) {
return unauthorized(res);
}
const { id, type, start_at, end_at, url } = req.query;
const websiteId = +id;
const startDate = new Date(+start_at);
const endDate = new Date(+end_at);
if (sessionColumns.includes(type)) {
const data = await getSessionMetrics(websiteId, startDate, endDate, type, { url });
return ok(res, data);
}
if (pageviewColumns.includes(type) || type === 'event') {
let domain;
if (type === 'referrer') {
const website = getWebsiteById(websiteId);
if (!website) {
return badRequest(res);
}
domain = website.domain;
}
const data = await getPageviewMetrics(
websiteId,
startDate,
endDate,
getColumn(type),
getTable(type),
{
domain,
url: type !== 'url' && url,
},
);
return ok(res, data);
}
}
return methodNotAllowed(res);
};

View file

@ -0,0 +1,36 @@
import moment from 'moment-timezone';
import { getPageviewStats } from 'lib/queries';
import { ok, badRequest, methodNotAllowed, unauthorized } from 'lib/response';
import { allowQuery } from 'lib/auth';
const unitTypes = ['year', 'month', 'hour', 'day'];
export default async (req, res) => {
if (req.method === 'GET') {
if (!(await allowQuery(req))) {
return unauthorized(res);
}
const { id, start_at, end_at, unit, tz, url, ref } = req.query;
const websiteId = +id;
const startDate = new Date(+start_at);
const endDate = new Date(+end_at);
if (!moment.tz.zone(tz) || !unitTypes.includes(unit)) {
return badRequest(res);
}
const [pageviews, sessions] = await Promise.all([
getPageviewStats(websiteId, startDate, endDate, tz, unit, '*', { url, ref }),
getPageviewStats(websiteId, startDate, endDate, tz, unit, 'distinct session_id', {
url,
ref,
}),
]);
return ok(res, { pageviews, sessions });
}
return methodNotAllowed(res);
};

View file

@ -0,0 +1,20 @@
import { resetWebsite } from 'lib/queries';
import { methodNotAllowed, ok, unauthorized } from 'lib/response';
import { allowQuery } from 'lib/auth';
export default async (req, res) => {
const { id } = req.query;
const websiteId = +id;
if (req.method === 'POST') {
if (!(await allowQuery(req))) {
return unauthorized(res);
}
await resetWebsite(websiteId);
return ok(res);
}
return methodNotAllowed(res);
};

View file

@ -0,0 +1,36 @@
import { getWebsiteStats } from 'lib/queries';
import { methodNotAllowed, ok, unauthorized } from 'lib/response';
import { allowQuery } from 'lib/auth';
export default async (req, res) => {
if (req.method === 'GET') {
if (!(await allowQuery(req))) {
return unauthorized(res);
}
const { id, start_at, end_at, url, ref } = req.query;
const websiteId = +id;
const startDate = new Date(+start_at);
const endDate = new Date(+end_at);
const distance = end_at - start_at;
const prevStartDate = new Date(+start_at - distance);
const prevEndDate = new Date(+end_at - distance);
const metrics = await getWebsiteStats(websiteId, startDate, endDate, { url, ref });
const prevPeriod = await getWebsiteStats(websiteId, prevStartDate, prevEndDate, { url, ref });
const stats = Object.keys(metrics[0]).reduce((obj, key) => {
obj[key] = {
value: Number(metrics[0][key]) || 0,
change: Number(metrics[0][key] - prevPeriod[0][key]) || 0,
};
return obj;
}, {});
return ok(res, stats);
}
return methodNotAllowed(res);
};

View file

@ -0,0 +1,43 @@
import { updateWebsite, createWebsite, getWebsiteById } from 'lib/queries';
import { useAuth } from 'lib/middleware';
import { uuid, getRandomChars } from 'lib/crypto';
import { ok, unauthorized, methodNotAllowed } from 'lib/response';
export default async (req, res) => {
await useAuth(req, res);
const { user_id, is_admin } = req.auth;
const { website_id, enable_share_url } = req.body;
if (req.method === 'POST') {
const { name, domain } = req.body;
if (website_id) {
const website = await getWebsiteById(website_id);
if (website.user_id !== user_id && !is_admin) {
return unauthorized(res);
}
let { share_id } = website;
if (enable_share_url) {
share_id = share_id ? share_id : getRandomChars(8);
} else {
share_id = null;
}
await updateWebsite(website_id, { name, domain, share_id });
return ok(res);
} else {
const website_uuid = uuid();
const share_id = enable_share_url ? getRandomChars(8) : null;
const website = await createWebsite(user_id, { website_uuid, name, domain, share_id });
return ok(res, website);
}
}
return methodNotAllowed(res);
};

23
pages/api/websites.js Normal file
View file

@ -0,0 +1,23 @@
import { getUserWebsites } from 'lib/queries';
import { useAuth } from 'lib/middleware';
import { ok, methodNotAllowed, unauthorized } from 'lib/response';
export default async (req, res) => {
await useAuth(req, res);
const { user_id: current_user_id, is_admin } = req.auth;
const { user_id } = req.query;
const userId = +user_id;
if (req.method === 'GET') {
if (userId && userId !== current_user_id && !is_admin) {
return unauthorized(res);
}
const websites = await getUserWebsites(userId || current_user_id);
return ok(res, websites);
}
return methodNotAllowed(res);
};

View file

@ -0,0 +1,22 @@
import React from 'react';
import { useRouter } from 'next/router';
import Layout from 'components/layout/Layout';
import WebsiteList from 'components/pages/WebsiteList';
import useRequireLogin from 'hooks/useRequireLogin';
export default function DashboardPage() {
const { loading } = useRequireLogin();
const router = useRouter();
const { id } = router.query;
const userId = id?.[0];
if (loading) {
return null;
}
return (
<Layout>
<WebsiteList userId={userId} />
</Layout>
);
}

12
pages/index.js Normal file
View file

@ -0,0 +1,12 @@
import { useEffect } from 'react';
import { useRouter } from 'next/router';
export default function DefaultPage() {
const router = useRouter();
useEffect(() => {
router.push('/dashboard');
}, []);
return null;
}

11
pages/login.js Normal file
View file

@ -0,0 +1,11 @@
import React from 'react';
import Layout from 'components/layout/Layout';
import LoginForm from 'components/forms/LoginForm';
export default function LoginPage() {
return (
<Layout title="login" header={false} footer={false} center>
<LoginForm />
</Layout>
);
}

18
pages/logout.js Normal file
View file

@ -0,0 +1,18 @@
import { useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { useRouter } from 'next/router';
import { get } from 'lib/web';
import { updateUser } from 'redux/actions/user';
export default function LogoutPage() {
const dispatch = useDispatch();
const router = useRouter();
const { basePath } = router;
useEffect(() => {
dispatch(updateUser(null));
get(`${basePath}/api/auth/logout`).then(() => router.push('/login'));
}, []);
return null;
}

18
pages/realtime.js Normal file
View file

@ -0,0 +1,18 @@
import React from 'react';
import Layout from 'components/layout/Layout';
import RealtimeDashboard from 'components/pages/RealtimeDashboard';
import useRequireLogin from 'hooks/useRequireLogin';
export default function RealtimePage() {
const { loading } = useRequireLogin();
if (loading) {
return null;
}
return (
<Layout>
<RealtimeDashboard />
</Layout>
);
}

View file

@ -0,0 +1,3 @@
import Index from './index';
export default Index;

18
pages/settings/index.js Normal file
View file

@ -0,0 +1,18 @@
import React from 'react';
import Layout from 'components/layout/Layout';
import Settings from 'components/pages/Settings';
import useRequireLogin from 'hooks/useRequireLogin';
export default function SettingsPage() {
const { loading } = useRequireLogin();
if (loading) {
return null;
}
return (
<Layout>
<Settings />
</Layout>
);
}

View file

@ -0,0 +1,3 @@
import Index from './index';
export default Index;

24
pages/share/[...id].js Normal file
View file

@ -0,0 +1,24 @@
import React from 'react';
import { useRouter } from 'next/router';
import Layout from 'components/layout/Layout';
import WebsiteDetails from 'components/pages/WebsiteDetails';
import useShareToken from 'hooks/useShareToken';
export default function SharePage() {
const router = useRouter();
const { id } = router.query;
const shareId = id?.[0];
const shareToken = useShareToken(shareId);
if (!shareToken) {
return null;
}
const { websiteId } = shareToken;
return (
<Layout>
<WebsiteDetails websiteId={websiteId} />
</Layout>
);
}

18
pages/test.js Normal file
View file

@ -0,0 +1,18 @@
import React from 'react';
import Layout from 'components/layout/Layout';
import TestConsole from 'components/pages/TestConsole';
import useRequireLogin from 'hooks/useRequireLogin';
export default function TestPage() {
const { loading } = useRequireLogin();
if (loading) {
return null;
}
return (
<Layout>
<TestConsole />
</Layout>
);
}

23
pages/website/[...id].js Normal file
View file

@ -0,0 +1,23 @@
import React from 'react';
import { useRouter } from 'next/router';
import Layout from 'components/layout/Layout';
import WebsiteDetails from 'components/pages/WebsiteDetails';
import useRequireLogin from 'hooks/useRequireLogin';
export default function DetailsPage() {
const { loading } = useRequireLogin();
const router = useRouter();
const { id } = router.query;
if (!id || loading) {
return null;
}
const [websiteId] = id;
return (
<Layout>
<WebsiteDetails websiteId={websiteId} />
</Layout>
);
}