Merge pull request #1479 from umami-software/dev

Merge Dev into Master
pull/1492/head v1.38.0
Mike Cao 2022-09-05 18:40:21 -07:00 committed by GitHub
commit 9916265150
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
136 changed files with 3180 additions and 2600 deletions

View File

@ -4,7 +4,12 @@
"es2020": true, "es2020": true,
"node": true "node": true
}, },
"extends": ["eslint:recommended", "plugin:prettier/recommended", "next"], "extends": [
"eslint:recommended",
"plugin:prettier/recommended",
"plugin:import/recommended",
"next"
],
"parserOptions": { "parserOptions": {
"ecmaFeatures": { "ecmaFeatures": {
"jsx": true "jsx": true
@ -12,7 +17,27 @@
"ecmaVersion": 11, "ecmaVersion": 11,
"sourceType": "module" "sourceType": "module"
}, },
"settings": {
"import/resolver": {
"alias": {
"map": [
["assets", "./assets"],
["components", "./components"],
["db", "./db"],
["hooks", "./hooks"],
["lang", "./lang"],
["lib", "./lib"],
["public", "./public"],
["queries", "./queries"],
["store", "./store"],
["styles", "./styles"]
],
"extensions": [".ts", ".js", ".jsx", ".json"]
}
}
},
"rules": { "rules": {
"no-console": "error",
"react/display-name": "off", "react/display-name": "off",
"react/react-in-jsx-scope": "off", "react/react-in-jsx-scope": "off",
"react/prop-types": "off", "react/prop-types": "off",

View File

@ -7,6 +7,7 @@ on: [push]
env: env:
DATABASE_TYPE: postgresql DATABASE_TYPE: postgresql
SKIP_DB_CHECK: 1
jobs: jobs:
build: build:

3
.gitignore vendored
View File

@ -35,3 +35,6 @@ yarn-error.log*
.env.development.local .env.development.local
.env.test.local .env.test.local
.env.production.local .env.production.local
*.dev.yml

View File

@ -20,7 +20,7 @@ ENV BASE_PATH $BASE_PATH
ENV NEXT_TELEMETRY_DISABLED 1 ENV NEXT_TELEMETRY_DISABLED 1
RUN yarn build RUN yarn build-docker
# Production image, copy all the files and run next # Production image, copy all the files and run next
FROM node:16-alpine AS runner FROM node:16-alpine AS runner

View File

@ -76,12 +76,12 @@ docker-compose up
Alternatively, to pull just the Umami Docker image with PostgreSQL support: Alternatively, to pull just the Umami Docker image with PostgreSQL support:
```bash ```bash
docker pull docker.umami.dev/umami-software/umami:postgresql-latest docker pull docker.umami.is/umami-software/umami:postgresql-latest
``` ```
Or with MySQL support: Or with MySQL support:
```bash ```bash
docker pull docker.umami.dev/umami-software/umami:mysql-latest docker pull docker.umami.is/umami-software/umami:mysql-latest
``` ```
## Getting updates ## Getting updates
@ -92,7 +92,6 @@ To get the latest features, simply do a pull, install any new dependencies, and
git pull git pull
yarn install yarn install
yarn build yarn build
yarn update-db
``` ```
To update the Docker image, simply pull the new images and rebuild: To update the Docker image, simply pull the new images and rebuild:

View File

@ -18,6 +18,10 @@ export const filterOptions = [
), ),
value: '24hour', value: '24hour',
}, },
{
label: <FormattedMessage id="label.yesterday" defaultMessage="Yesterday" />,
value: '-1day',
},
{ {
label: <FormattedMessage id="label.this-week" defaultMessage="This week" />, label: <FormattedMessage id="label.this-week" defaultMessage="This week" />,
value: '1week', value: '1week',

View File

@ -1,10 +1,10 @@
import React from 'react'; import React from 'react';
import Link from 'next/link';
import classNames from 'classnames'; import classNames from 'classnames';
import Link from 'next/link';
import { safeDecodeURI } from 'next-basics';
import usePageQuery from 'hooks/usePageQuery'; import usePageQuery from 'hooks/usePageQuery';
import { safeDecodeURI } from 'lib/url';
import Icon from './Icon';
import External from 'assets/arrow-up-right-from-square.svg'; import External from 'assets/arrow-up-right-from-square.svg';
import Icon from './Icon';
import styles from './FilterLink.module.css'; import styles from './FilterLink.module.css';
export default function FilterLink({ id, value, label, externalUrl }) { export default function FilterLink({ id, value, label, externalUrl }) {
@ -25,7 +25,7 @@ export default function FilterLink({ id, value, label, externalUrl }) {
</a> </a>
</Link> </Link>
{externalUrl && ( {externalUrl && (
<a href={externalUrl} target="_blank" rel="noreferrer noopener" className={styles.link}> <a className={styles.link} href={externalUrl} target="_blank" rel="noreferrer noopener">
<Icon icon={<External />} className={styles.icon} /> <Icon icon={<External />} className={styles.icon} />
</a> </a>
)} )}

View File

@ -3,9 +3,9 @@ import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import styles from './Loading.module.css'; import styles from './Loading.module.css';
function Loading({ className }) { function Loading({ className, overlay = false }) {
return ( return (
<div className={classNames(styles.loading, className)}> <div className={classNames(styles.loading, { [styles.overlay]: overlay }, className)}>
<div /> <div />
<div /> <div />
<div /> <div />
@ -15,6 +15,7 @@ function Loading({ className }) {
Loading.propTypes = { Loading.propTypes = {
className: PropTypes.string, className: PropTypes.string,
overlay: PropTypes.bool,
}; };
export default Loading; export default Loading;

View File

@ -21,6 +21,14 @@
margin: 0; margin: 0;
} }
.loading.overlay {
height: 100%;
width: 100%;
z-index: 10;
background: var(--gray400);
opacity: 0.4;
}
.loading div { .loading div {
width: 10px; width: 10px;
height: 10px; height: 10px;
@ -30,6 +38,10 @@
animation-fill-mode: both; animation-fill-mode: both;
} }
.loading.overlay div {
background: var(--gray900);
}
.loading div + div { .loading div + div {
margin-left: 10px; margin-left: 10px;
} }

View File

@ -1,8 +1,8 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { setItem } from 'next-basics';
import ButtonLayout from 'components/layout/ButtonLayout'; import ButtonLayout from 'components/layout/ButtonLayout';
import useStore, { checkVersion } from 'store/version'; import useStore, { checkVersion } from 'store/version';
import { setItem } from 'lib/web';
import { REPO_URL, VERSION_CHECK } from 'lib/constants'; import { REPO_URL, VERSION_CHECK } from 'lib/constants';
import Button from './Button'; import Button from './Button';
import styles from './UpdateNotice.module.css'; import styles from './UpdateNotice.module.css';

View File

@ -8,6 +8,7 @@ import FormLayout, {
FormMessage, FormMessage,
FormRow, FormRow,
} from 'components/layout/FormLayout'; } from 'components/layout/FormLayout';
import Loading from 'components/common/Loading';
import useApi from 'hooks/useApi'; import useApi from 'hooks/useApi';
const CONFIRMATION_WORD = 'DELETE'; const CONFIRMATION_WORD = 'DELETE';
@ -29,8 +30,11 @@ const validate = ({ confirmation }) => {
export default function DeleteForm({ values, onSave, onClose }) { export default function DeleteForm({ values, onSave, onClose }) {
const { del } = useApi(); const { del } = useApi();
const [message, setMessage] = useState(); const [message, setMessage] = useState();
const [deleting, setDeleting] = useState(false);
const handleSubmit = async ({ type, id }) => { const handleSubmit = async ({ type, id }) => {
setDeleting(true);
const { ok, data } = await del(`/${type}/${id}`); const { ok, data } = await del(`/${type}/${id}`);
if (ok) { if (ok) {
@ -39,11 +43,14 @@ export default function DeleteForm({ values, onSave, onClose }) {
setMessage( setMessage(
data || <FormattedMessage id="message.failure" defaultMessage="Something went wrong." />, data || <FormattedMessage id="message.failure" defaultMessage="Something went wrong." />,
); );
setDeleting(false);
} }
}; };
return ( return (
<FormLayout> <FormLayout>
{deleting && <Loading overlay />}
<Formik <Formik
initialValues={{ confirmation: '', ...values }} initialValues={{ confirmation: '', ...values }}
validate={validate} validate={validate}

View File

@ -1,6 +1,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { Formik, Form, Field } from 'formik'; import { Formik, Form, Field } from 'formik';
import { setItem } from 'next-basics';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import Button from 'components/common/Button'; import Button from 'components/common/Button';
import FormLayout, { import FormLayout, {
@ -11,7 +12,6 @@ import FormLayout, {
} from 'components/layout/FormLayout'; } from 'components/layout/FormLayout';
import Icon from 'components/common/Icon'; import Icon from 'components/common/Icon';
import useApi from 'hooks/useApi'; import useApi from 'hooks/useApi';
import { setItem } from 'lib/web';
import { AUTH_TOKEN } from 'lib/constants'; import { AUTH_TOKEN } from 'lib/constants';
import { setUser } from 'store/app'; import { setUser } from 'store/app';
import Logo from 'assets/logo.svg'; import Logo from 'assets/logo.svg';

View File

@ -1,6 +1,6 @@
import React, { useState } from 'react'; import React, { useEffect, useState } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { Formik, Form, Field } from 'formik'; import { Formik, Form, Field, useFormikContext } from 'formik';
import Button from 'components/common/Button'; import Button from 'components/common/Button';
import FormLayout, { import FormLayout, {
FormButtons, FormButtons,
@ -11,10 +11,14 @@ import FormLayout, {
import Checkbox from 'components/common/Checkbox'; import Checkbox from 'components/common/Checkbox';
import { DOMAIN_REGEX } from 'lib/constants'; import { DOMAIN_REGEX } from 'lib/constants';
import useApi from 'hooks/useApi'; import useApi from 'hooks/useApi';
import useFetch from 'hooks/useFetch';
import useUser from 'hooks/useUser';
import styles from './WebsiteEditForm.module.css';
const initialValues = { const initialValues = {
name: '', name: '',
domain: '', domain: '',
owner: '',
public: false, public: false,
}; };
@ -33,8 +37,45 @@ const validate = ({ name, domain }) => {
return errors; return errors;
}; };
const OwnerDropDown = ({ user, accounts }) => {
console.info(styles);
const { setFieldValue, values } = useFormikContext();
useEffect(() => {
if (values.user_id != null && values.owner === '') {
setFieldValue('owner', values.user_id.toString());
} else if (user?.user_id && values.owner === '') {
setFieldValue('owner', user.user_id.toString());
}
}, [accounts, setFieldValue, user, values]);
if (user?.is_admin) {
return (
<FormRow>
<label htmlFor="owner">
<FormattedMessage id="label.owner" defaultMessage="Owner" />
</label>
<div>
<Field as="select" name="owner" className={styles.dropdown}>
{accounts?.map(acc => (
<option key={acc.user_id} value={acc.user_id}>
{acc.username}
</option>
))}
</Field>
<FormError name="owner" />
</div>
</FormRow>
);
} else {
return null;
}
};
export default function WebsiteEditForm({ values, onSave, onClose }) { export default function WebsiteEditForm({ values, onSave, onClose }) {
const { post } = useApi(); const { post } = useApi();
const { data: accounts } = useFetch(`/accounts`);
const { user } = useUser();
const [message, setMessage] = useState(); const [message, setMessage] = useState();
const handleSubmit = async values => { const handleSubmit = async values => {
@ -72,10 +113,18 @@ export default function WebsiteEditForm({ values, onSave, onClose }) {
<FormattedMessage id="label.domain" defaultMessage="Domain" /> <FormattedMessage id="label.domain" defaultMessage="Domain" />
</label> </label>
<div> <div>
<Field name="domain" type="text" placeholder="example.com" /> <Field
name="domain"
type="text"
placeholder="example.com"
spellcheck="false"
autocapitalize="off"
autocorrect="off"
/>
<FormError name="domain" /> <FormError name="domain" />
</div> </div>
</FormRow> </FormRow>
<OwnerDropDown accounts={accounts} user={user} />
<FormRow> <FormRow>
<label /> <label />
<Field name="enable_share_url"> <Field name="enable_share_url">

View File

@ -0,0 +1,5 @@
.dropdown {
-moz-appearance: none;
-webkit-appearance: none;
appearance: none;
}

View File

@ -24,7 +24,8 @@
flex: 1 1; flex: 1 1;
} }
.row > div > input { .row > div > input,
.row > div > select {
width: 100%; width: 100%;
min-width: 240px; min-width: 240px;
} }

View File

@ -9,7 +9,7 @@ import HamburgerButton from 'components/common/HamburgerButton';
import UpdateNotice from 'components/common/UpdateNotice'; import UpdateNotice from 'components/common/UpdateNotice';
import UserButton from 'components/settings/UserButton'; import UserButton from 'components/settings/UserButton';
import { HOMEPAGE_URL } from 'lib/constants'; import { HOMEPAGE_URL } from 'lib/constants';
import useConfig from '/hooks/useConfig'; import useConfig from 'hooks/useConfig';
import useUser from 'hooks/useUser'; import useUser from 'hooks/useUser';
import Logo from 'assets/logo.svg'; import Logo from 'assets/logo.svg';
import styles from './Header.module.css'; import styles from './Header.module.css';

View File

@ -1,26 +1,6 @@
import React from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import styles from './Page.module.css'; import styles from './Page.module.css';
export default class Page extends React.Component { export default function Page({ className, children }) {
getSnapshotBeforeUpdate() { return <div className={classNames(styles.page, className)}>{children}</div>;
if (window.pageXOffset === 0 && window.pageYOffset === 0) return null;
// Return the scrolled position as the snapshot value
return { x: window.pageXOffset, y: window.pageYOffset };
}
/* eslint-disable no-unused-vars */
componentDidUpdate(prevProps, prevState, snapshot) {
if (snapshot !== null) {
// Restore the scrolled position after re-rendering
window.scrollTo(snapshot.x, snapshot.y);
}
}
/* eslint-enable no-unused-vars */
render() {
const { className, children } = this.props;
return <div className={classNames(styles.page, className)}>{children}</div>;
}
} }

View File

@ -1,8 +1,8 @@
import React from 'react'; import React from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { safeDecodeURI } from 'next-basics';
import Button from 'components/common/Button'; import Button from 'components/common/Button';
import Times from 'assets/times.svg'; import Times from 'assets/times.svg';
import { safeDecodeURI } from 'lib/url';
import styles from './FilterTags.module.css'; import styles from './FilterTags.module.css';
export default function FilterTags({ params, onClick }) { export default function FilterTags({ params, onClick }) {

View File

@ -29,9 +29,9 @@ const MetricCard = ({
: !reverseColors : !reverseColors
? styles.negative ? styles.negative
: styles.positive : styles.positive
}`} } ${change >= 0 ? styles.plusSign : ''}`}
> >
{changeProps.x.interpolate(x => `${change >= 0 ? '+' : ''}${format(x)}`)} {changeProps.x.interpolate(x => format(x))}
</animated.span> </animated.span>
)} )}
</div> </div>

View File

@ -37,3 +37,7 @@
.change.negative { .change.negative {
color: var(--red500); color: var(--red500);
} }
.change.plusSign::before {
content: '+';
}

View File

@ -1,5 +1,5 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { FormattedMessage } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import firstBy from 'thenby'; import firstBy from 'thenby';
import classNames from 'classnames'; import classNames from 'classnames';
import Link from 'components/common/Link'; import Link from 'components/common/Link';
@ -14,6 +14,10 @@ import DataTable from './DataTable';
import { DEFAULT_ANIMATION_DURATION } from 'lib/constants'; import { DEFAULT_ANIMATION_DURATION } from 'lib/constants';
import styles from './MetricsTable.module.css'; import styles from './MetricsTable.module.css';
const messages = defineMessages({
more: { id: 'label.more', defaultMessage: 'More' },
});
export default function MetricsTable({ export default function MetricsTable({
websiteId, websiteId,
type, type,
@ -31,6 +35,7 @@ export default function MetricsTable({
router, router,
query: { url, referrer, os, browser, device, country }, query: { url, referrer, os, browser, device, country },
} = usePageQuery(); } = usePageQuery();
const { formatMessage } = useIntl();
const { data, loading, error } = useFetch( const { data, loading, error } = useFetch(
`/website/${websiteId}/metrics`, `/website/${websiteId}/metrics`,
@ -80,7 +85,7 @@ export default function MetricsTable({
size="small" size="small"
iconRight iconRight
> >
<FormattedMessage id="label.more" defaultMessage="More" /> {formatMessage(messages.more)}
</Link> </Link>
)} )}
</div> </div>

View File

@ -1,10 +1,10 @@
import { useState } from 'react'; import { useState } from 'react';
import { useIntl, defineMessages } from 'react-intl'; import { useIntl, defineMessages } from 'react-intl';
import MetricsTable from './MetricsTable'; import { safeDecodeURI } from 'next-basics';
import Tag from 'components/common/Tag'; import Tag from 'components/common/Tag';
import FilterButtons from 'components/common/FilterButtons';
import { paramFilter } from 'lib/filters'; import { paramFilter } from 'lib/filters';
import { safeDecodeURI } from 'lib/url'; import MetricsTable from './MetricsTable';
import FilterButtons from '../common/FilterButtons';
const FILTER_COMBINED = 0; const FILTER_COMBINED = 0;
const FILTER_RAW = 1; const FILTER_RAW = 1;

View File

@ -3,7 +3,7 @@ import { defineMessages, useIntl } from 'react-intl';
import { DragDropContext, Draggable, Droppable } from 'react-beautiful-dnd'; import { DragDropContext, Draggable, Droppable } from 'react-beautiful-dnd';
import classNames from 'classnames'; import classNames from 'classnames';
import Button from 'components/common/Button'; import Button from 'components/common/Button';
import { sortArrayByMap } from 'lib/array'; import { firstBy } from 'thenby';
import useDashboard, { saveDashboard } from 'store/dashboard'; import useDashboard, { saveDashboard } from 'store/dashboard';
import styles from './DashboardEdit.module.css'; import styles from './DashboardEdit.module.css';
@ -21,9 +21,13 @@ export default function DashboardEdit({ websites }) {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const [order, setOrder] = useState(websiteOrder || []); const [order, setOrder] = useState(websiteOrder || []);
const ordered = useMemo(() => sortArrayByMap(websites, order, 'website_id'), [websites, order]); const ordered = useMemo(
() =>
console.log({ order, ordered }); websites
.map(website => ({ ...website, order: order.indexOf(website.website_id) }))
.sort(firstBy('order')),
[websites, order],
);
function handleWebsiteDrag({ destination, source }) { function handleWebsiteDrag({ destination, source }) {
if (!destination || destination.index === source.index) return; if (!destination || destination.index === source.index) return;
@ -32,7 +36,7 @@ export default function DashboardEdit({ websites }) {
const [removed] = orderedWebsites.splice(source.index, 1); const [removed] = orderedWebsites.splice(source.index, 1);
orderedWebsites.splice(destination.index, 0, removed); orderedWebsites.splice(destination.index, 0, removed);
setOrder(orderedWebsites.map(({ website_id }) => website_id)); setOrder(orderedWebsites.map((website) => website?.website_id || 0));
} }
function handleSave() { function handleSave() {

View File

@ -28,8 +28,6 @@ export default function TestConsole() {
const website = data.find(({ website_id }) => website_id === +websiteId); const website = data.find(({ website_id }) => website_id === +websiteId);
const selectedValue = options.find(({ value }) => value === website?.website_id)?.value; const selectedValue = options.find(({ value }) => value === website?.website_id)?.value;
console.log({ websiteId, data, options, website });
function handleSelect(value) { function handleSelect(value) {
router.push(`/console/${value}`); router.push(`/console/${value}`);
} }

View File

@ -6,8 +6,8 @@ import EmptyPlaceholder from 'components/common/EmptyPlaceholder';
import Arrow from 'assets/arrow-right.svg'; import Arrow from 'assets/arrow-right.svg';
import styles from './WebsiteList.module.css'; import styles from './WebsiteList.module.css';
import useDashboard from 'store/dashboard'; import useDashboard from 'store/dashboard';
import { sortArrayByMap } from 'lib/array';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { firstBy } from 'thenby';
const messages = defineMessages({ const messages = defineMessages({
noWebsites: { noWebsites: {
@ -24,10 +24,11 @@ export default function WebsiteList({ websites, showCharts, limit }) {
const { websiteOrder } = useDashboard(); const { websiteOrder } = useDashboard();
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
console.log({ websiteOrder });
const ordered = useMemo( const ordered = useMemo(
() => sortArrayByMap(websites, websiteOrder, 'website_id'), () =>
websites
.map(website => ({ ...website, order: websiteOrder.indexOf(website.website_id) || 0 }))
.sort(firstBy('order')),
[websites, websiteOrder], [websites, websiteOrder],
); );

View File

@ -1,11 +1,11 @@
import React from 'react'; import React from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { removeItem } from 'next-basics';
import MenuButton from 'components/common/MenuButton'; import MenuButton from 'components/common/MenuButton';
import Icon from 'components/common/Icon'; import Icon from 'components/common/Icon';
import User from 'assets/user.svg'; import User from 'assets/user.svg';
import styles from './UserButton.module.css'; import styles from './UserButton.module.css';
import { removeItem } from 'lib/web';
import { AUTH_TOKEN } from 'lib/constants'; import { AUTH_TOKEN } from 'lib/constants';
import useUser from 'hooks/useUser'; import useUser from 'hooks/useUser';

View File

@ -0,0 +1,130 @@
SET allow_experimental_object_type = 1;
-- Create Pageview
CREATE TABLE pageview
(
website_id UInt32,
session_uuid UUID,
created_at DateTime('UTC'),
url String,
referrer String
)
engine = MergeTree PRIMARY KEY (session_uuid, created_at)
ORDER BY (session_uuid, created_at)
SETTINGS index_granularity = 8192;
CREATE TABLE pageview_queue (
website_id UInt32,
session_uuid UUID,
created_at DateTime('UTC'),
url String,
referrer String
)
ENGINE = Kafka
SETTINGS kafka_broker_list = 'kafka1:19092,kafka2:19093,kafka3:19094', -- input broker list
kafka_topic_list = 'pageview',
kafka_group_name = 'pageview_consumer_group',
kafka_format = 'JSONEachRow',
kafka_max_block_size = 1048576,
kafka_skip_broken_messages = 1;
CREATE MATERIALIZED VIEW pageview_queue_mv TO pageview AS
SELECT website_id,
session_uuid,
created_at,
url,
referrer
FROM pageview_queue;
-- Create Session
CREATE TABLE session
(
session_uuid UUID,
website_id UInt32,
created_at DateTime('UTC'),
hostname LowCardinality(String),
browser LowCardinality(String),
os LowCardinality(String),
device LowCardinality(String),
screen LowCardinality(String),
language LowCardinality(String),
country LowCardinality(String)
)
engine = MergeTree PRIMARY KEY (session_uuid, created_at)
ORDER BY (session_uuid, created_at)
SETTINGS index_granularity = 8192;
CREATE TABLE session_queue (
session_uuid UUID,
website_id UInt32,
created_at DateTime('UTC'),
hostname LowCardinality(String),
browser LowCardinality(String),
os LowCardinality(String),
device LowCardinality(String),
screen LowCardinality(String),
language LowCardinality(String),
country LowCardinality(String)
)
ENGINE = Kafka
SETTINGS kafka_broker_list = 'kafka1:19092,kafka2:19093,kafka3:19094', -- input broker list
kafka_topic_list = 'session',
kafka_group_name = 'session_consumer_group',
kafka_format = 'JSONEachRow',
kafka_max_block_size = 1048576,
kafka_skip_broken_messages = 1;
CREATE MATERIALIZED VIEW session_queue_mv TO session AS
SELECT session_uuid,
website_id,
created_at,
hostname,
browser,
os,
device,
screen,
language,
country
FROM session_queue;
-- Create event
CREATE TABLE event
(
event_uuid UUID,
website_id UInt32,
session_uuid UUID,
created_at DateTime('UTC'),
url String,
event_name String,
event_data JSON
)
engine = MergeTree PRIMARY KEY (event_uuid, created_at)
ORDER BY (event_uuid, created_at)
SETTINGS index_granularity = 8192;
CREATE TABLE event_queue (
event_uuid UUID,
website_id UInt32,
session_uuid UUID,
created_at DateTime('UTC'),
url String,
event_name String,
event_data String
)
ENGINE = Kafka
SETTINGS kafka_broker_list = 'kafka1:19092,kafka2:19093,kafka3:19094', -- input broker list
kafka_topic_list = 'event',
kafka_group_name = 'event_consumer_group',
kafka_format = 'JSONEachRow',
kafka_max_block_size = 1048576,
kafka_skip_broken_messages = 1;
CREATE MATERIALIZED VIEW event_queue_mv TO event AS
SELECT event_uuid,
website_id,
session_uuid,
created_at,
url,
event_name,
event_data
FROM event_queue;

View File

@ -0,0 +1,35 @@
-- DropForeignKey
ALTER TABLE `event` DROP FOREIGN KEY `event_ibfk_2`;
-- DropForeignKey
ALTER TABLE `event` DROP FOREIGN KEY `event_ibfk_1`;
-- DropForeignKey
ALTER TABLE `pageview` DROP FOREIGN KEY `pageview_ibfk_2`;
-- DropForeignKey
ALTER TABLE `pageview` DROP FOREIGN KEY `pageview_ibfk_1`;
-- DropForeignKey
ALTER TABLE `session` DROP FOREIGN KEY `session_ibfk_1`;
-- DropForeignKey
ALTER TABLE `website` DROP FOREIGN KEY `website_ibfk_1`;
-- AddForeignKey
ALTER TABLE `event` ADD CONSTRAINT `event_session_id_fkey` FOREIGN KEY (`session_id`) REFERENCES `session`(`session_id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `event` ADD CONSTRAINT `event_website_id_fkey` FOREIGN KEY (`website_id`) REFERENCES `website`(`website_id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `pageview` ADD CONSTRAINT `pageview_session_id_fkey` FOREIGN KEY (`session_id`) REFERENCES `session`(`session_id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `pageview` ADD CONSTRAINT `pageview_website_id_fkey` FOREIGN KEY (`website_id`) REFERENCES `website`(`website_id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `session` ADD CONSTRAINT `session_website_id_fkey` FOREIGN KEY (`website_id`) REFERENCES `website`(`website_id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `website` ADD CONSTRAINT `website_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `account`(`user_id`) ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -24,8 +24,8 @@ model event {
created_at DateTime? @default(now()) @db.Timestamp(0) created_at DateTime? @default(now()) @db.Timestamp(0)
url String @db.VarChar(500) url String @db.VarChar(500)
event_name String @db.VarChar(50) event_name String @db.VarChar(50)
session session @relation(fields: [session_id], references: [session_id], onDelete: Cascade, onUpdate: NoAction, map: "event_ibfk_2") session session @relation(fields: [session_id], references: [session_id])
website website @relation(fields: [website_id], references: [website_id], onDelete: Cascade, onUpdate: NoAction, map: "event_ibfk_1") website website @relation(fields: [website_id], references: [website_id])
event_data event_data? event_data event_data?
@@index([created_at]) @@index([created_at])
@ -34,7 +34,7 @@ model event {
} }
model event_data { model event_data {
event_data_id Int @id @default(autoincrement()) event_data_id Int @id @default(autoincrement()) @db.UnsignedInt
event_id Int @unique @db.UnsignedInt event_id Int @unique @db.UnsignedInt
event_data Json event_data Json
event event @relation(fields: [event_id], references: [event_id]) event event @relation(fields: [event_id], references: [event_id])
@ -47,8 +47,8 @@ model pageview {
created_at DateTime? @default(now()) @db.Timestamp(0) created_at DateTime? @default(now()) @db.Timestamp(0)
url String @db.VarChar(500) url String @db.VarChar(500)
referrer String? @db.VarChar(500) referrer String? @db.VarChar(500)
session session @relation(fields: [session_id], references: [session_id], onDelete: Cascade, onUpdate: NoAction, map: "pageview_ibfk_2") session session @relation(fields: [session_id], references: [session_id])
website website @relation(fields: [website_id], references: [website_id], onDelete: Cascade, onUpdate: NoAction, map: "pageview_ibfk_1") website website @relation(fields: [website_id], references: [website_id])
@@index([created_at]) @@index([created_at])
@@index([session_id]) @@index([session_id])
@ -69,7 +69,7 @@ model session {
screen String? @db.VarChar(11) screen String? @db.VarChar(11)
language String? @db.VarChar(35) language String? @db.VarChar(35)
country String? @db.Char(2) country String? @db.Char(2)
website website @relation(fields: [website_id], references: [website_id], onDelete: Cascade, onUpdate: NoAction, map: "session_ibfk_1") website website @relation(fields: [website_id], references: [website_id])
event event[] event event[]
pageview pageview[] pageview pageview[]
@ -85,7 +85,7 @@ model website {
domain String? @db.VarChar(500) domain String? @db.VarChar(500)
share_id String? @unique() @db.VarChar(64) share_id String? @unique() @db.VarChar(64)
created_at DateTime? @default(now()) @db.Timestamp(0) created_at DateTime? @default(now()) @db.Timestamp(0)
account account @relation(fields: [user_id], references: [user_id], onDelete: Cascade, onUpdate: NoAction, map: "website_ibfk_1") account account @relation(fields: [user_id], references: [user_id])
event event[] event event[]
pageview pageview[] pageview pageview[]
session session[] session session[]

View File

@ -0,0 +1,35 @@
-- DropForeignKey
ALTER TABLE "event" DROP CONSTRAINT IF EXISTS "event_session_id_fkey";
-- DropForeignKey
ALTER TABLE "event" DROP CONSTRAINT IF EXISTS "event_website_id_fkey";
-- DropForeignKey
ALTER TABLE "pageview" DROP CONSTRAINT IF EXISTS "pageview_session_id_fkey";
-- DropForeignKey
ALTER TABLE "pageview" DROP CONSTRAINT IF EXISTS "pageview_website_id_fkey";
-- DropForeignKey
ALTER TABLE "session" DROP CONSTRAINT IF EXISTS "session_website_id_fkey";
-- DropForeignKey
ALTER TABLE "website" DROP CONSTRAINT IF EXISTS "website_user_id_fkey";
-- AddForeignKey
ALTER TABLE "event" ADD CONSTRAINT "event_session_id_fkey" FOREIGN KEY ("session_id") REFERENCES "session"("session_id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "event" ADD CONSTRAINT "event_website_id_fkey" FOREIGN KEY ("website_id") REFERENCES "website"("website_id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "pageview" ADD CONSTRAINT "pageview_session_id_fkey" FOREIGN KEY ("session_id") REFERENCES "session"("session_id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "pageview" ADD CONSTRAINT "pageview_website_id_fkey" FOREIGN KEY ("website_id") REFERENCES "website"("website_id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "session" ADD CONSTRAINT "session_website_id_fkey" FOREIGN KEY ("website_id") REFERENCES "website"("website_id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "website" ADD CONSTRAINT "website_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "account"("user_id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -24,8 +24,8 @@ model event {
created_at DateTime? @default(now()) @db.Timestamptz(6) created_at DateTime? @default(now()) @db.Timestamptz(6)
url String @db.VarChar(500) url String @db.VarChar(500)
event_name String @db.VarChar(50) event_name String @db.VarChar(50)
session session @relation(fields: [session_id], references: [session_id], onDelete: Cascade) session session @relation(fields: [session_id], references: [session_id])
website website @relation(fields: [website_id], references: [website_id], onDelete: Cascade) website website @relation(fields: [website_id], references: [website_id])
event_data event_data? event_data event_data?
@@index([created_at]) @@index([created_at])
@ -47,8 +47,8 @@ model pageview {
created_at DateTime? @default(now()) @db.Timestamptz(6) created_at DateTime? @default(now()) @db.Timestamptz(6)
url String @db.VarChar(500) url String @db.VarChar(500)
referrer String? @db.VarChar(500) referrer String? @db.VarChar(500)
session session @relation(fields: [session_id], references: [session_id], onDelete: Cascade) session session @relation(fields: [session_id], references: [session_id])
website website @relation(fields: [website_id], references: [website_id], onDelete: Cascade) website website @relation(fields: [website_id], references: [website_id])
@@index([created_at]) @@index([created_at])
@@index([session_id]) @@index([session_id])
@ -69,9 +69,9 @@ model session {
screen String? @db.VarChar(11) screen String? @db.VarChar(11)
language String? @db.VarChar(35) language String? @db.VarChar(35)
country String? @db.Char(2) country String? @db.Char(2)
website website @relation(fields: [website_id], references: [website_id], onDelete: Cascade) website website @relation(fields: [website_id], references: [website_id])
pageview pageview[]
event event[] event event[]
pageview pageview[]
@@index([created_at]) @@index([created_at])
@@index([website_id]) @@index([website_id])
@ -85,10 +85,10 @@ model website {
domain String? @db.VarChar(500) domain String? @db.VarChar(500)
share_id String? @unique @db.VarChar(64) share_id String? @unique @db.VarChar(64)
created_at DateTime? @default(now()) @db.Timestamptz(6) created_at DateTime? @default(now()) @db.Timestamptz(6)
account account @relation(fields: [user_id], references: [user_id], onDelete: Cascade) account account @relation(fields: [user_id], references: [user_id])
event event[]
pageview pageview[] pageview pageview[]
session session[] session session[]
event event[]
@@index([user_id]) @@index([user_id])
} }

View File

@ -1,6 +1,6 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { get, post, put, del, getItem } from 'lib/web'; import { get, post, put, del, getItem } from 'next-basics';
import { AUTH_TOKEN, SHARE_TOKEN_HEADER } from 'lib/constants'; import { AUTH_TOKEN, SHARE_TOKEN_HEADER } from 'lib/constants';
import useStore from 'store/app'; import useStore from 'store/app';

View File

@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { get } from 'lib/web'; import { get } from 'next-basics';
import enUS from 'public/intl/country/en-US.json'; import enUS from 'public/intl/country/en-US.json';
const countryNames = { const countryNames = {

View File

@ -1,7 +1,7 @@
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { parseISO } from 'date-fns'; import { parseISO } from 'date-fns';
import { getDateRange } from 'lib/date'; import { getDateRange } from 'lib/date';
import { getItem, setItem } from 'lib/web'; import { getItem, setItem } from 'next-basics';
import { DATE_RANGE_CONFIG, DEFAULT_DATE_RANGE } from 'lib/constants'; import { DATE_RANGE_CONFIG, DEFAULT_DATE_RANGE } from 'lib/constants';
import useForceUpdate from './useForceUpdate'; import useForceUpdate from './useForceUpdate';
import useLocale from './useLocale'; import useLocale from './useLocale';

View File

@ -8,7 +8,7 @@ export default function useFetch(url, options = {}, update = []) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [count, setCount] = useState(0); const [count, setCount] = useState(0);
const { get } = useApi(); const { get } = useApi();
const { params = {}, headers = {}, disabled, delay = 0, interval, onDataLoad } = options; const { params = {}, headers = {}, disabled = false, delay = 0, interval, onDataLoad } = options;
async function loadData(params) { async function loadData(params) {
try { try {
@ -29,7 +29,9 @@ export default function useFetch(url, options = {}, update = []) {
onDataLoad?.(data); onDataLoad?.(data);
} catch (e) { } catch (e) {
// eslint-disable-next-line no-console
console.error(e); console.error(e);
setError(e); setError(e);
} finally { } finally {
setLoading(false); setLoading(false);
@ -44,7 +46,7 @@ export default function useFetch(url, options = {}, update = []) {
clearTimeout(id); clearTimeout(id);
}; };
} }
}, [url, !!disabled, count, ...update]); }, [url, disabled, count, ...update]);
useEffect(() => { useEffect(() => {
if (interval && !disabled) { if (interval && !disabled) {
@ -54,7 +56,7 @@ export default function useFetch(url, options = {}, update = []) {
clearInterval(id); clearInterval(id);
}; };
} }
}, [interval, !!disabled]); }, [interval, disabled]);
return { ...response, error, loading }; return { ...response, error, loading };
} }

View File

@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { get } from 'lib/web'; import { get } from 'next-basics';
import enUS from 'public/intl/language/en-US.json'; import enUS from 'public/intl/language/en-US.json';
const languageNames = { const languageNames = {

View File

@ -1,6 +1,6 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { get, setItem } from 'lib/web'; import { get, setItem } from 'next-basics';
import { LOCALE_CONFIG } from 'lib/constants'; import { LOCALE_CONFIG } from 'lib/constants';
import { getDateLocale, getTextDirection } from 'lib/lang'; import { getDateLocale, getTextDirection } from 'lib/lang';
import useStore, { setLocale } from 'store/app'; import useStore, { setLocale } from 'store/app';
@ -48,5 +48,14 @@ export default function useLocale() {
} }
}, [locale]); }, [locale]);
useEffect(() => {
const url = new URL(window.location.href);
const locale = url.searchParams.get('locale');
if (locale) {
saveLocale(locale);
}
}, []);
return { locale, saveLocale, messages, dir, dateLocale }; return { locale, saveLocale, messages, dir, dateLocale };
} }

View File

@ -1,10 +1,11 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { getQueryString } from 'lib/url'; import { buildUrl } from 'next-basics';
export default function usePageQuery() { export default function usePageQuery() {
const router = useRouter(); const router = useRouter();
const { pathname, search } = location; const { pathname, search } = location;
const { asPath } = router;
const query = useMemo(() => { const query = useMemo(() => {
if (!search) { if (!search) {
@ -23,11 +24,7 @@ export default function usePageQuery() {
}, [search]); }, [search]);
function resolve(params) { function resolve(params) {
const search = getQueryString({ ...query, ...params }); return buildUrl(asPath.split('?')[0], { ...query, ...params });
const { asPath } = router;
return `${asPath.split('?')[0]}${search}`;
} }
return { pathname, query, resolve, router }; return { pathname, query, resolve, router };

View File

@ -1,6 +1,6 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import useStore, { setTheme } from 'store/app'; import useStore, { setTheme } from 'store/app';
import { getItem, setItem } from 'lib/web'; import { getItem, setItem } from 'next-basics';
import { THEME_CONFIG } from 'lib/constants'; import { THEME_CONFIG } from 'lib/constants';
const selector = state => state.theme; const selector = state => state.theme;
@ -23,5 +23,14 @@ export default function useTheme() {
document.body.setAttribute('data-theme', theme); document.body.setAttribute('data-theme', theme);
}, [theme]); }, [theme]);
useEffect(() => {
const url = new URL(window.location.href);
const theme = url.searchParams.get('theme');
if (['light', 'dark'].includes(theme)) {
saveTheme(theme);
}
}, []);
return [theme, saveTheme]; return [theme, saveTheme];
} }

View File

@ -1,6 +1,6 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { getTimezone } from 'lib/date'; import { getTimezone } from 'lib/date';
import { getItem, setItem } from 'lib/web'; import { getItem, setItem } from 'next-basics';
import { TIMEZONE_CONFIG } from 'lib/constants'; import { TIMEZONE_CONFIG } from 'lib/constants';
export default function useTimezone() { export default function useTimezone() {

View File

@ -62,6 +62,7 @@
"label.username": "Nom d'usuari", "label.username": "Nom d'usuari",
"label.view-details": "Veure els detalls", "label.view-details": "Veure els detalls",
"label.websites": "Llocs web", "label.websites": "Llocs web",
"label.yesterday": "Ahir",
"message.active-users": "{x} {x, plural, one {visitant actual} other {visitants actuals}}", "message.active-users": "{x} {x, plural, one {visitant actual} other {visitants actuals}}",
"message.confirm-delete": "Segur que vols esborrar {target}?", "message.confirm-delete": "Segur que vols esborrar {target}?",
"message.confirm-reset": "Segur que vols restablir les estadístiques de {target}?", "message.confirm-reset": "Segur que vols restablir les estadístiques de {target}?",

View File

@ -62,6 +62,7 @@
"label.username": "Benutzername", "label.username": "Benutzername",
"label.view-details": "Details anzeigen", "label.view-details": "Details anzeigen",
"label.websites": "Webseiten", "label.websites": "Webseiten",
"label.yesterday": "Gestern",
"message.active-users": "{x} {x, plural, one {aktiver Besucher} other {aktive Besucher}}", "message.active-users": "{x} {x, plural, one {aktiver Besucher} other {aktive Besucher}}",
"message.confirm-delete": "Sind Sie sich sicher, {target} zu löschen?", "message.confirm-delete": "Sind Sie sich sicher, {target} zu löschen?",
"message.confirm-reset": "Sind Sie sicher, dass Sie die Statistiken von {target} zurücksetzen wollen?", "message.confirm-reset": "Sind Sie sicher, dass Sie die Statistiken von {target} zurücksetzen wollen?",

View File

@ -62,6 +62,7 @@
"label.username": "Username", "label.username": "Username",
"label.view-details": "View details", "label.view-details": "View details",
"label.websites": "Websites", "label.websites": "Websites",
"label.yesterday": "Yesterday",
"message.active-users": "{x} current {x, plural, one {visitor} other {visitors}}", "message.active-users": "{x} current {x, plural, one {visitor} other {visitors}}",
"message.confirm-delete": "Are you sure you want to delete {target}?", "message.confirm-delete": "Are you sure you want to delete {target}?",
"message.confirm-reset": "Are you sure you want to reset {target}'s statistics?", "message.confirm-reset": "Are you sure you want to reset {target}'s statistics?",

View File

@ -62,6 +62,7 @@
"label.username": "Username", "label.username": "Username",
"label.view-details": "View details", "label.view-details": "View details",
"label.websites": "Websites", "label.websites": "Websites",
"label.yesterday": "Yesterday",
"message.active-users": "{x} current {x, plural, one {visitor} other {visitors}}", "message.active-users": "{x} current {x, plural, one {visitor} other {visitors}}",
"message.confirm-delete": "Are you sure you want to delete {target}?", "message.confirm-delete": "Are you sure you want to delete {target}?",
"message.confirm-reset": "Are you sure you want to reset {target}'s statistics?", "message.confirm-reset": "Are you sure you want to reset {target}'s statistics?",

View File

@ -62,6 +62,7 @@
"label.username": "Nombre de usuario", "label.username": "Nombre de usuario",
"label.view-details": "Ver detalles", "label.view-details": "Ver detalles",
"label.websites": "Sitios", "label.websites": "Sitios",
"label.yesterday": "Ayer",
"message.active-users": "{x} {x, plural, one {activo} other {activos}}", "message.active-users": "{x} {x, plural, one {activo} other {activos}}",
"message.confirm-delete": "¿Estás seguro(a) de querer eliminar {target}?", "message.confirm-delete": "¿Estás seguro(a) de querer eliminar {target}?",
"message.confirm-reset": "¿Seguro que deseas restablecer las estadísticas de {target}?", "message.confirm-reset": "¿Seguro que deseas restablecer las estadísticas de {target}?",

View File

@ -5,7 +5,7 @@
"label.administrator": "Administrateur", "label.administrator": "Administrateur",
"label.all": "Tout", "label.all": "Tout",
"label.all-time": "Toutes les données", "label.all-time": "Toutes les données",
"label.all-websites": "Tous les sites web", "label.all-websites": "Tous les sites",
"label.back": "Retour", "label.back": "Retour",
"label.cancel": "Annuler", "label.cancel": "Annuler",
"label.change-password": "Changer le mot de passe", "label.change-password": "Changer le mot de passe",
@ -27,7 +27,7 @@
"label.enable-share-url": "Activer l'URL de partage", "label.enable-share-url": "Activer l'URL de partage",
"label.invalid": "Invalide", "label.invalid": "Invalide",
"label.invalid-domain": "Domaine invalide", "label.invalid-domain": "Domaine invalide",
"label.language": "Langage", "label.language": "Langue",
"label.last-days": "{x} derniers jours", "label.last-days": "{x} derniers jours",
"label.last-hours": "{x} dernières heures", "label.last-hours": "{x} dernières heures",
"label.logged-in-as": "Connecté en tant que {username}", "label.logged-in-as": "Connecté en tant que {username}",
@ -52,7 +52,7 @@
"label.share-url": "Partager l'URL", "label.share-url": "Partager l'URL",
"label.single-day": "Journée", "label.single-day": "Journée",
"label.theme": "Thème", "label.theme": "Thème",
"label.this-month": "Ce mois ci", "label.this-month": "Ce mois",
"label.this-week": "Cette semaine", "label.this-week": "Cette semaine",
"label.this-year": "Cette année", "label.this-year": "Cette année",
"label.timezone": "Fuseau horaire", "label.timezone": "Fuseau horaire",
@ -62,12 +62,13 @@
"label.username": "Nom d'utilisateur", "label.username": "Nom d'utilisateur",
"label.view-details": "Voir les details", "label.view-details": "Voir les details",
"label.websites": "Sites", "label.websites": "Sites",
"label.yesterday": "Hier",
"message.active-users": "{x} {x, plural, one {visiteur} other {visiteurs}} actuellement", "message.active-users": "{x} {x, plural, one {visiteur} other {visiteurs}} actuellement",
"message.confirm-delete": "Êtes-vous sûr de vouloir supprimer {target} ?", "message.confirm-delete": "Êtes-vous sûr de vouloir supprimer {target} ?",
"message.confirm-reset": "Êtes-vous sûr de vouloir réinistialiser les statistiques de {target} ?", "message.confirm-reset": "Êtes-vous sûr de vouloir réinitialiser les statistiques de {target} ?",
"message.copied": "Copié !", "message.copied": "Copié !",
"message.delete-warning": "Toutes les données associées seront également supprimées.", "message.delete-warning": "Toutes les données associées seront également supprimées.",
"message.edit-dashboard": "Modifier l'ordre des sites", "message.edit-dashboard": "Modifier le tableau de bord",
"message.failure": "Un problème est survenu.", "message.failure": "Un problème est survenu.",
"message.get-share-url": "Obtenir l'URL de partage", "message.get-share-url": "Obtenir l'URL de partage",
"message.get-tracking-code": "Obtenir le code de suivi", "message.get-tracking-code": "Obtenir le code de suivi",
@ -76,14 +77,14 @@
"message.log.visitor": "Visiteur de {country} utilisant {browser} sur {os} {device}", "message.log.visitor": "Visiteur de {country} utilisant {browser} sur {os} {device}",
"message.new-version-available": "Une nouvelle version de umami {version} est disponible !", "message.new-version-available": "Une nouvelle version de umami {version} est disponible !",
"message.no-data-available": "Pas de données disponibles.", "message.no-data-available": "Pas de données disponibles.",
"message.no-websites-configured": "Vous n'avez configuré aucun site Web.", "message.no-websites-configured": "Vous n'avez configuré aucun site.",
"message.page-not-found": "Page non trouvée.", "message.page-not-found": "Page non trouvée.",
"message.powered-by": "Propulsé par {name}", "message.powered-by": "Propulsé par {name}",
"message.reset-warning": "Toutes les statistiques pour ce site seront supprimés, mais votre code de suivi restera intact.", "message.reset-warning": "Toutes les statistiques pour ce site seront supprimées, mais votre code de suivi restera intact.",
"message.save-success": "Enregistré avec succès.", "message.save-success": "Enregistré avec succès.",
"message.share-url": "Ceci est l'URL partagée pour {target}.", "message.share-url": "Ceci est l'URL partagée pour {target}.",
"message.toggle-charts": "Afficher/Masquer les graphiques", "message.toggle-charts": "Afficher/Masquer les graphiques",
"message.track-stats": "Pour suivre les statistiques de {target}, placez le code suivant dans la section {head} de votre site Web.", "message.track-stats": "Pour suivre les statistiques de {target}, placez le code suivant dans la section {head} de votre site.",
"message.type-delete": "Tapez {delete} dans la case ci-dessous pour confirmer.", "message.type-delete": "Tapez {delete} dans la case ci-dessous pour confirmer.",
"message.type-reset": "Tapez {reset} dans la case ci-dessous pour confirmer.", "message.type-reset": "Tapez {reset} dans la case ci-dessous pour confirmer.",
"metrics.actions": "Actions", "metrics.actions": "Actions",
@ -99,13 +100,13 @@
"metrics.events": "Événements", "metrics.events": "Événements",
"metrics.filter.combined": "Combiné", "metrics.filter.combined": "Combiné",
"metrics.filter.raw": "Brut", "metrics.filter.raw": "Brut",
"metrics.languages": "Langages", "metrics.languages": "Langues",
"metrics.operating-systems": "Systèmes d'exploitation", "metrics.operating-systems": "Systèmes d'exploitation",
"metrics.page-views": "Pages vues", "metrics.page-views": "Pages vues",
"metrics.pages": "Pages", "metrics.pages": "Pages",
"metrics.query-parameters": "Query parameters", "metrics.query-parameters": "Paramètres d'URL",
"metrics.referrers": "Sources", "metrics.referrers": "Sources",
"metrics.screens": "Tailles d'écran", "metrics.screens": "Résolutions d'écran",
"metrics.unique-visitors": "Visiteurs uniques", "metrics.unique-visitors": "Visiteurs uniques",
"metrics.views": "Vues", "metrics.views": "Vues",
"metrics.visitors": "Visiteurs" "metrics.visitors": "Visiteurs"

View File

@ -62,6 +62,7 @@
"label.username": "Nome utente", "label.username": "Nome utente",
"label.view-details": "Vedi dettagli", "label.view-details": "Vedi dettagli",
"label.websites": "Siti web", "label.websites": "Siti web",
"label.yesterday": "Ieri",
"message.active-users": "{x} {x, plural, one {visitatore} other {visitatori}} online", "message.active-users": "{x} {x, plural, one {visitatore} other {visitatori}} online",
"message.confirm-delete": "Sei sicuro di voler eliminare {target}?", "message.confirm-delete": "Sei sicuro di voler eliminare {target}?",
"message.confirm-reset": "Sei sicuro di voler azzerare le statistiche di {target}?", "message.confirm-reset": "Sei sicuro di voler azzerare le statistiche di {target}?",

View File

@ -67,7 +67,7 @@
"message.confirm-reset": "您确定要重置 {target} 的数据吗?", "message.confirm-reset": "您确定要重置 {target} 的数据吗?",
"message.copied": "复制成功!", "message.copied": "复制成功!",
"message.delete-warning": "所有相关数据将会被删除。", "message.delete-warning": "所有相关数据将会被删除。",
"message.edit-dashboard": "Edit dashboard", "message.edit-dashboard": "编辑仪表板",
"message.failure": "出现错误。", "message.failure": "出现错误。",
"message.get-share-url": "获取共享链接", "message.get-share-url": "获取共享链接",
"message.get-tracking-code": "获取跟踪代码", "message.get-tracking-code": "获取跟踪代码",
@ -103,7 +103,7 @@
"metrics.operating-systems": "操作系统", "metrics.operating-systems": "操作系统",
"metrics.page-views": "页面浏览量", "metrics.page-views": "页面浏览量",
"metrics.pages": "网页", "metrics.pages": "网页",
"metrics.query-parameters": "Query parameters", "metrics.query-parameters": "查询参数",
"metrics.referrers": "来源域名", "metrics.referrers": "来源域名",
"metrics.screens": "屏幕尺寸", "metrics.screens": "屏幕尺寸",
"metrics.unique-visitors": "独立访客", "metrics.unique-visitors": "独立访客",

View File

@ -27,7 +27,7 @@
"label.enable-share-url": "啟用分享連結", "label.enable-share-url": "啟用分享連結",
"label.invalid": "無效輸入", "label.invalid": "無效輸入",
"label.invalid-domain": "無效域名", "label.invalid-domain": "無效域名",
"label.language": "Language", "label.language": "語言",
"label.last-days": "最近 {x} 天", "label.last-days": "最近 {x} 天",
"label.last-hours": "最近 {x} 小時", "label.last-hours": "最近 {x} 小時",
"label.logged-in-as": "用戶名: {username}", "label.logged-in-as": "用戶名: {username}",
@ -67,7 +67,7 @@
"message.confirm-reset": "您確定要重置 {target} 的數據嗎?", "message.confirm-reset": "您確定要重置 {target} 的數據嗎?",
"message.copied": "複製成功!", "message.copied": "複製成功!",
"message.delete-warning": "所有相關數據將會被刪除。", "message.delete-warning": "所有相關數據將會被刪除。",
"message.edit-dashboard": "Edit dashboard", "message.edit-dashboard": "編輯管理面板",
"message.failure": "出現錯誤。", "message.failure": "出現錯誤。",
"message.get-share-url": "獲得分享連結", "message.get-share-url": "獲得分享連結",
"message.get-tracking-code": "獲得追蹤代碼", "message.get-tracking-code": "獲得追蹤代碼",
@ -103,7 +103,7 @@
"metrics.operating-systems": "操作系統", "metrics.operating-systems": "操作系統",
"metrics.page-views": "網頁流量", "metrics.page-views": "網頁流量",
"metrics.pages": "網頁", "metrics.pages": "網頁",
"metrics.query-parameters": "Query parameters", "metrics.query-parameters": "查詢參數",
"metrics.referrers": "指入域名", "metrics.referrers": "指入域名",
"metrics.screens": "屏幕尺寸", "metrics.screens": "屏幕尺寸",
"metrics.unique-visitors": "獨立訪客", "metrics.unique-visitors": "獨立訪客",

View File

@ -1,20 +1,27 @@
import { parseSecureToken, parseToken } from './crypto'; import { parseSecureToken, parseToken, getItem } from 'next-basics';
import { SHARE_TOKEN_HEADER } from './constants'; import { AUTH_TOKEN, SHARE_TOKEN_HEADER } from './constants';
import { getWebsiteById } from 'queries'; import { getWebsiteById } from 'queries';
import { secret } from './crypto';
export async function getAuthToken(req) { export async function getAuthToken(req) {
try { try {
const token = req.headers.authorization; const token = req.headers.authorization;
return parseSecureToken(token.split(' ')[1]); return parseSecureToken(token.split(' ')[1], secret());
} catch { } catch {
return null; return null;
} }
} }
export function getAuthHeader() {
const token = getItem(AUTH_TOKEN);
return token ? { authorization: `Bearer ${token}` } : {};
}
export async function isValidToken(token, validation) { export async function isValidToken(token, validation) {
try { try {
const result = await parseToken(token); const result = parseToken(token, secret());
if (typeof validation === 'object') { if (typeof validation === 'object') {
return !Object.keys(validation).find(key => result[key] !== validation[key]); return !Object.keys(validation).find(key => result[key] !== validation[key]);

201
lib/clickhouse.js Normal file
View File

@ -0,0 +1,201 @@
import { ClickHouse } from 'clickhouse';
import dateFormat from 'dateformat';
import debug from 'debug';
import { FILTER_IGNORED } from 'lib/constants';
import { CLICKHOUSE } from 'lib/db';
export const CLICKHOUSE_DATE_FORMATS = {
minute: '%Y-%m-%d %H:%M:00',
hour: '%Y-%m-%d %H:00:00',
day: '%Y-%m-%d',
month: '%Y-%m-01',
year: '%Y-01-01',
};
const log = debug('umami:clickhouse');
function getClient() {
const {
hostname,
port,
pathname,
username = 'default',
password,
} = new URL(process.env.CLICKHOUSE_URL);
const client = new ClickHouse({
url: hostname,
port: Number(port),
format: 'json',
config: {
database: pathname.replace('/', ''),
},
basicAuth: password ? { username, password } : null,
});
if (process.env.NODE_ENV !== 'production') {
global[CLICKHOUSE] = client;
}
log('Clickhouse initialized');
return client;
}
function getDateStringQuery(data, unit) {
return `formatDateTime(${data}, '${CLICKHOUSE_DATE_FORMATS[unit]}')`;
}
function getDateQuery(field, unit, timezone) {
if (timezone) {
return `date_trunc('${unit}', ${field}, '${timezone}')`;
}
return `date_trunc('${unit}', ${field})`;
}
function getDateFormat(date) {
return `'${dateFormat(date, 'UTC:yyyy-mm-dd HH:MM:ss')}'`;
}
function getBetweenDates(field, start_at, end_at) {
return `${field} between ${getDateFormat(start_at)}
and ${getDateFormat(end_at)}`;
}
function getFilterQuery(table, column, filters = {}, params = []) {
const query = Object.keys(filters).reduce((arr, key) => {
const filter = filters[key];
if (filter === undefined || filter === FILTER_IGNORED) {
return arr;
}
switch (key) {
case 'url':
if (table === 'pageview' || table === 'event') {
arr.push(`and ${table}.${key}=$${params.length + 1}`);
params.push(decodeURIComponent(filter));
}
break;
case 'os':
case 'browser':
case 'device':
case 'country':
if (table === 'session') {
arr.push(`and ${table}.${key}=$${params.length + 1}`);
params.push(decodeURIComponent(filter));
}
break;
case 'event_name':
if (table === 'event') {
arr.push(`and ${table}.${key}=$${params.length + 1}`);
params.push(decodeURIComponent(filter));
}
break;
case 'referrer':
if (table === 'pageview' || table === 'event') {
arr.push(`and ${table}.referrer like $${params.length + 1}`);
params.push(`%${decodeURIComponent(filter)}%`);
}
break;
case 'domain':
if (table === 'pageview') {
arr.push(`and ${table}.referrer not like $${params.length + 1}`);
arr.push(`and ${table}.referrer not like '/%'`);
params.push(`%://${filter}/%`);
}
break;
case 'query':
if (table === 'pageview') {
arr.push(`and ${table}.url like '%?%'`);
}
}
return arr;
}, []);
return query.join('\n');
}
function parseFilters(table, column, filters = {}, params = [], sessionKey = 'session_id') {
const { domain, url, event_url, referrer, os, browser, device, country, event_name, query } =
filters;
const pageviewFilters = { domain, url, referrer, query };
const sessionFilters = { os, browser, device, country };
const eventFilters = { url: event_url, event_name };
return {
pageviewFilters,
sessionFilters,
eventFilters,
event: { event_name },
joinSession:
os || browser || device || country
? `inner join session on ${table}.${sessionKey} = session.${sessionKey}`
: '',
pageviewQuery: getFilterQuery('pageview', column, pageviewFilters, params),
sessionQuery: getFilterQuery('session', column, sessionFilters, params),
eventQuery: getFilterQuery('event', column, eventFilters, params),
};
}
function formatQuery(str, params = []) {
let formattedString = str;
params.forEach((param, i) => {
let replace = param;
if (typeof param === 'string' || param instanceof String) {
replace = `'${replace}'`;
}
formattedString = formattedString.replace(`$${i + 1}`, replace);
});
return formattedString;
}
async function rawQuery(query, params = []) {
let formattedQuery = formatQuery(query, params);
if (process.env.LOG_QUERY) {
log(formattedQuery);
}
return clickhouse.query(formattedQuery).toPromise();
}
async function findUnique(data) {
if (data.length > 1) {
throw `${data.length} records found when expecting 1.`;
}
return data[0] ?? null;
}
async function findFirst(data) {
return data[0] ?? null;
}
// Initialization
const clickhouse = process.env.CLICKHOUSE_URL && (global[CLICKHOUSE] || getClient());
export default {
client: clickhouse,
log,
getDateStringQuery,
getDateQuery,
getDateFormat,
getBetweenDates,
getFilterQuery,
parseFilters,
findUnique,
findFirst,
rawQuery,
};

View File

@ -67,35 +67,6 @@ export const EVENT_COLORS = [
'#ffec16', '#ffec16',
]; ];
export const RELATIONAL = 'relational';
export const POSTGRESQL = 'postgresql';
export const MYSQL = 'mysql';
export const CLICKHOUSE = 'clickhouse';
export const MYSQL_DATE_FORMATS = {
minute: '%Y-%m-%d %H:%i:00',
hour: '%Y-%m-%d %H:00:00',
day: '%Y-%m-%d',
month: '%Y-%m-01',
year: '%Y-01-01',
};
export const POSTGRESQL_DATE_FORMATS = {
minute: 'YYYY-MM-DD HH24:MI:00',
hour: 'YYYY-MM-DD HH24:00:00',
day: 'YYYY-MM-DD',
month: 'YYYY-MM-01',
year: 'YYYY-01-01',
};
export const CLICKHOUSE_DATE_FORMATS = {
minute: '%Y-%m-%d %H:%M:00',
hour: '%Y-%m-%d %H:00:00',
day: '%Y-%m-%d',
month: '%Y-%m-01',
year: '%Y-01-01',
};
export const FILTER_IGNORED = Symbol.for('filter-ignored'); export const FILTER_IGNORED = Symbol.for('filter-ignored');
export const DOMAIN_REGEX = export const DOMAIN_REGEX =
@ -106,6 +77,7 @@ export const LAPTOP_SCREEN_WIDTH = 1024;
export const MOBILE_SCREEN_WIDTH = 479; export const MOBILE_SCREEN_WIDTH = 479;
export const URL_LENGTH = 500; export const URL_LENGTH = 500;
export const EVENT_NAME_LENGTH = 50;
export const DESKTOP_OS = [ export const DESKTOP_OS = [
'Windows 3.11', 'Windows 3.11',

View File

@ -1,74 +1,19 @@
import crypto from 'crypto'; import { v4, v5 } from 'uuid';
import { v4, v5, validate } from 'uuid';
import bcrypt from 'bcryptjs';
import { JWT, JWE, JWK } from 'jose';
import { startOfMonth } from 'date-fns'; import { startOfMonth } from 'date-fns';
import { hash } from 'next-basics';
const SALT_ROUNDS = 10;
const KEY = JWK.asKey(Buffer.from(secret()));
const ROTATING_SALT = hash(startOfMonth(new Date()).toUTCString());
const CHARS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
export function hash(...args) {
return crypto.createHash('sha512').update(args.join('')).digest('hex');
}
export function secret() { export function secret() {
return hash(process.env.HASH_SALT || process.env.DATABASE_URL); return hash(process.env.HASH_SALT || process.env.DATABASE_URL);
} }
export function salt() { export function salt() {
return v5(hash(secret(), ROTATING_SALT), v5.DNS); const ROTATING_SALT = hash(startOfMonth(new Date()).toUTCString());
return hash([secret(), ROTATING_SALT]);
} }
export function uuid(...args) { export function uuid(...args) {
if (!args.length) return v4(); if (!args.length) return v4();
return v5(args.join(''), salt()); return v5(hash([...args, salt()]), v5.DNS);
}
export function isValidUuid(s) {
return validate(s);
}
export function getRandomChars(n) {
let s = '';
for (let i = 0; i < n; i++) {
s += CHARS[Math.floor(Math.random() * CHARS.length)];
}
return s;
}
export function hashPassword(password) {
return bcrypt.hashSync(password, SALT_ROUNDS);
}
export function checkPassword(password, hash) {
return bcrypt.compareSync(password, hash);
}
export async function createToken(payload) {
return JWT.sign(payload, KEY);
}
export async function parseToken(token) {
try {
return JWT.verify(token, KEY);
} catch {
return null;
}
}
export async function createSecureToken(payload) {
return JWE.encrypt(await createToken(payload), KEY);
}
export async function parseSecureToken(token) {
try {
const result = await JWE.decrypt(token, KEY);
return parseToken(result.toString());
} catch {
return null;
}
} }

View File

@ -7,6 +7,8 @@ import {
addYears, addYears,
subHours, subHours,
subDays, subDays,
subMonths,
subYears,
startOfMinute, startOfMinute,
startOfHour, startOfHour,
startOfDay, startOfDay,
@ -39,7 +41,7 @@ export function getDateRange(value, locale = 'en-US') {
const now = new Date(); const now = new Date();
const dateLocale = getDateLocale(locale); const dateLocale = getDateLocale(locale);
const match = value.match(/^(?<num>[0-9]+)(?<unit>hour|day|week|month|year)$/); const match = value.match(/^(?<num>[0-9-]+)(?<unit>hour|day|week|month|year)$/);
if (!match) return; if (!match) return;
@ -78,6 +80,39 @@ export function getDateRange(value, locale = 'en-US') {
} }
} }
if (+num === -1) {
switch (unit) {
case 'day':
return {
startDate: subDays(startOfDay(now), 1),
endDate: subDays(endOfDay(now), 1),
unit: 'hour',
value,
};
case 'week':
return {
startDate: subDays(startOfWeek(now, { locale: dateLocale }), 7),
endDate: subDays(endOfWeek(now, { locale: dateLocale }), 1),
unit: 'day',
value,
};
case 'month':
return {
startDate: subMonths(startOfMonth(now), 1),
endDate: subMonths(endOfMonth(now), 1),
unit: 'day',
value,
};
case 'year':
return {
startDate: subYears(startOfYear(now), 1),
endDate: subYears(endOfYear(now), 1),
unit: 'month',
value,
};
}
}
switch (unit) { switch (unit) {
case 'day': case 'day':
return { return {

301
lib/db.js
View File

@ -1,83 +1,18 @@
import { PrismaClient } from '@prisma/client'; export const PRISMA = 'prisma';
import { ClickHouse } from 'clickhouse'; export const POSTGRESQL = 'postgresql';
import chalk from 'chalk'; export const MYSQL = 'mysql';
import { export const CLICKHOUSE = 'clickhouse';
MYSQL, export const KAFKA = 'kafka';
MYSQL_DATE_FORMATS, export const KAFKA_PRODUCER = 'kafka-producer';
POSTGRESQL, export const REDIS = 'redis';
POSTGRESQL_DATE_FORMATS,
CLICKHOUSE,
RELATIONAL,
FILTER_IGNORED,
} from 'lib/constants';
import moment from 'moment-timezone';
import { CLICKHOUSE_DATE_FORMATS } from './constants';
// Fixes issue with converting bigint values
BigInt.prototype.toJSON = function () { BigInt.prototype.toJSON = function () {
return Number(this); return Number(this);
}; };
const options = { export function getDatabaseType(url = process.env.DATABASE_URL) {
log: [ const type = process.env.DATABASE_TYPE || (url && url.split(':')[0]);
{
emit: 'event',
level: 'query',
},
],
};
function logQuery(e) {
console.log(chalk.yellow(e.params), '->', e.query, chalk.greenBright(`${e.duration}ms`));
}
function getPrismaClient(options) {
const prisma = new PrismaClient(options);
if (process.env.LOG_QUERY) {
prisma.$on('query', logQuery);
}
return prisma;
}
function getClickhouseClient() {
if (!process.env.ANALYTICS_URL) {
return null;
}
const url = new URL(process.env.ANALYTICS_URL);
const database = url.pathname.replace('/', '');
return new ClickHouse({
url: url.hostname,
port: Number(url.port),
basicAuth: url.password
? {
username: url.username || 'default',
password: url.password,
}
: null,
format: 'json',
config: {
database,
},
});
}
const prisma = global.prisma || getPrismaClient(options);
const clickhouse = global.clickhouse || getClickhouseClient();
if (process.env.NODE_ENV !== 'production') {
global.prisma = prisma;
global.clickhouse = clickhouse;
}
export { prisma, clickhouse };
export function getDatabase() {
const type =
process.env.DATABASE_TYPE ||
(process.env.DATABASE_URL && process.env.DATABASE_URL.split(':')[0]);
if (type === 'postgres') { if (type === 'postgres') {
return POSTGRESQL; return POSTGRESQL;
@ -86,220 +21,18 @@ export function getDatabase() {
return type; return type;
} }
export function getAnalyticsDatabase() { export async function runQuery(queries) {
const type = const db = getDatabaseType(process.env.CLICKHOUSE_URL || process.env.DATABASE_URL);
process.env.ANALYTICS_TYPE ||
(process.env.ANALYTICS_URL && process.env.ANALYTICS_URL.split(':')[0]);
if (type === 'postgres') {
return POSTGRESQL;
}
if (!type) {
return getDatabase();
}
return type;
}
export function getDateStringQueryClickhouse(data, unit) {
return `formatDateTime(${data}, '${CLICKHOUSE_DATE_FORMATS[unit]}')`;
}
export function getDateQuery(field, unit, timezone) {
const db = getDatabase();
if (db === POSTGRESQL) {
if (timezone) {
return `to_char(date_trunc('${unit}', ${field} at time zone '${timezone}'), '${POSTGRESQL_DATE_FORMATS[unit]}')`;
}
return `to_char(date_trunc('${unit}', ${field}), '${POSTGRESQL_DATE_FORMATS[unit]}')`;
}
if (db === MYSQL) {
if (timezone) {
const tz = moment.tz(timezone).format('Z');
return `date_format(convert_tz(${field},'+00:00','${tz}'), '${MYSQL_DATE_FORMATS[unit]}')`;
}
return `date_format(${field}, '${MYSQL_DATE_FORMATS[unit]}')`;
}
}
export function getDateQueryClickhouse(field, unit, timezone) {
if (timezone) {
return `date_trunc('${unit}', ${field},'${timezone}')`;
}
return `date_trunc('${unit}', ${field})`;
}
export function getDateFormatClickhouse(date) {
return `parseDateTimeBestEffort('${date.toUTCString()}')`;
}
export function getBetweenDatesClickhouse(field, start_at, end_at) {
return `${field} between ${getDateFormatClickhouse(start_at)}
and ${getDateFormatClickhouse(end_at)}`;
}
export function getTimestampInterval(field) {
const db = getDatabase();
if (db === POSTGRESQL) {
return `floor(extract(epoch from max(${field}) - min(${field})))`;
}
if (db === MYSQL) {
return `floor(unix_timestamp(max(${field})) - unix_timestamp(min(${field})))`;
}
}
export function getFilterQuery(table, column, filters = {}, params = []) {
const query = Object.keys(filters).reduce((arr, key) => {
const filter = filters[key];
if (filter === undefined || filter === FILTER_IGNORED) {
return arr;
}
switch (key) {
case 'url':
if (table === 'pageview' || table === 'event') {
arr.push(`and ${table}.${key}=$${params.length + 1}`);
params.push(decodeURIComponent(filter));
}
break;
case 'os':
case 'browser':
case 'device':
case 'country':
if (table === 'session') {
arr.push(`and ${table}.${key}=$${params.length + 1}`);
params.push(decodeURIComponent(filter));
}
break;
case 'event_name':
if (table === 'event') {
arr.push(`and ${table}.${key}=$${params.length + 1}`);
params.push(decodeURIComponent(filter));
}
break;
case 'referrer':
if (table === 'pageview' || table === 'event') {
arr.push(`and ${table}.referrer like $${params.length + 1}`);
params.push(`%${decodeURIComponent(filter)}%`);
}
break;
case 'domain':
if (table === 'pageview') {
arr.push(`and ${table}.referrer not like $${params.length + 1}`);
arr.push(`and ${table}.referrer not like '/%'`);
params.push(`%://${filter}/%`);
}
break;
case 'query':
if (table === 'pageview') {
arr.push(`and ${table}.url like '%?%'`);
}
}
return arr;
}, []);
return query.join('\n');
}
export function parseFilters(table, column, filters = {}, params = [], sessionKey = 'session_id') {
const { domain, url, event_url, referrer, os, browser, device, country, event_name, query } =
filters;
console.log({ table, column, filters, params });
const pageviewFilters = { domain, url, referrer, query };
const sessionFilters = { os, browser, device, country };
const eventFilters = { url: event_url, event_name };
return {
pageviewFilters,
sessionFilters,
eventFilters,
event: { event_name },
joinSession:
os || browser || device || country
? `inner join session on ${table}.${sessionKey} = session.${sessionKey}`
: '',
pageviewQuery: getFilterQuery('pageview', column, pageviewFilters, params),
sessionQuery: getFilterQuery('session', column, sessionFilters, params),
eventQuery: getFilterQuery('event', column, eventFilters, params),
};
}
export function replaceQueryClickhouse(string, params = []) {
let formattedString = string;
params.forEach((a, i) => {
let replace = a;
if (typeof a === 'string' || a instanceof String) {
replace = `'${replace}'`;
}
formattedString = formattedString.replace(`$${i + 1}`, replace);
});
return formattedString;
}
export async function runQuery(query) {
return query.catch(e => {
throw e;
});
}
export async function rawQuery(query, params = []) {
const db = getDatabase();
if (db !== POSTGRESQL && db !== MYSQL) {
return Promise.reject(new Error('Unknown database.'));
}
const sql = db === MYSQL ? query.replace(/\$[0-9]+/g, '?') : query;
return runQuery(prisma.$queryRawUnsafe.apply(prisma, [sql, ...params]));
}
export async function rawQueryClickhouse(query, params = [], debug = false) {
let formattedQuery = replaceQueryClickhouse(query, params);
if (debug || process.env.LOG_QUERY) {
console.log(formattedQuery);
}
return clickhouse.query(formattedQuery).toPromise();
}
export async function findUnique(data) {
if (data.length > 1) {
throw `${data.length} records found when expecting 1.`;
}
return data[0] ?? null;
}
export async function runAnalyticsQuery(queries) {
const db = getAnalyticsDatabase();
if (db === POSTGRESQL || db === MYSQL) { if (db === POSTGRESQL || db === MYSQL) {
return queries[RELATIONAL](); return queries[PRISMA]();
} }
if (db === CLICKHOUSE) { if (db === CLICKHOUSE) {
if (queries[KAFKA]) {
return queries[KAFKA]();
}
return queries[CLICKHOUSE](); return queries[CLICKHOUSE]();
} }
} }

View File

@ -1,5 +1,3 @@
import { removeWWW } from './url';
export const urlFilter = data => { export const urlFilter = data => {
const isValidUrl = url => { const isValidUrl = url => {
return url !== '' && url !== null && !url.startsWith('#'); return url !== '' && url !== null && !url.startsWith('#');
@ -9,7 +7,7 @@ export const urlFilter = data => {
try { try {
const { pathname, search } = new URL(url, location.origin); const { pathname, search } = new URL(url, location.origin);
if (search.startsWith('?/')) { if (search.startsWith('?')) {
return `${pathname}${search}`; return `${pathname}${search}`;
} }
@ -49,7 +47,7 @@ export const refFilter = data => {
try { try {
const url = new URL(x); const url = new URL(x);
id = removeWWW(url.hostname) || url.href; id = url.hostname.replace(/www\./, '') || url.href;
} catch { } catch {
id = ''; id = '';
} }
@ -94,11 +92,7 @@ export const paramFilter = data => {
return obj; return obj;
}, {}); }, {});
const d = Object.keys(map).flatMap(key => return Object.keys(map).flatMap(key =>
Object.keys(map[key]).map(n => ({ x: `${key}=${n}`, p: key, v: n, y: map[key][n] })), Object.keys(map[key]).map(n => ({ x: `${key}=${n}`, p: key, v: n, y: map[key][n] })),
); );
console.log({ map, d });
return d;
}; };

View File

@ -74,7 +74,7 @@ export function stringToColor(str) {
let color = '#'; let color = '#';
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
let value = (hash >> (i * 8)) & 0xff; let value = (hash >> (i * 8)) & 0xff;
color += ('00' + value.toString(16)).substr(-2); color += ('00' + value.toString(16)).substring(-2);
} }
return color; return color;
} }

84
lib/kafka.js Normal file
View File

@ -0,0 +1,84 @@
import { Kafka, logLevel } from 'kafkajs';
import dateFormat from 'dateformat';
import debug from 'debug';
import { KAFKA, KAFKA_PRODUCER } from 'lib/db';
const log = debug('umami:kafka');
function getClient() {
const { username, password } = new URL(process.env.KAFKA_URL);
const brokers = process.env.KAFKA_BROKER.split(',');
const ssl =
username && password
? {
ssl: true,
sasl: {
mechanism: 'plain',
username,
password,
},
}
: {};
const client = new Kafka({
clientId: 'umami',
brokers: brokers,
connectionTimeout: 3000,
logLevel: logLevel.ERROR,
...ssl,
});
if (process.env.NODE_ENV !== 'production') {
global[KAFKA] = client;
}
return client;
}
async function getProducer() {
const producer = kafka.producer();
await producer.connect();
if (process.env.NODE_ENV !== 'production') {
global[KAFKA_PRODUCER] = producer;
}
return producer;
}
function getDateFormat(date) {
return dateFormat(date, 'UTC:yyyy-mm-dd HH:MM:ss');
}
async function sendMessage(params, topic) {
await producer.send({
topic,
messages: [
{
value: JSON.stringify(params),
},
],
acks: 0,
});
}
// Initialization
let kafka;
let producer;
(async () => {
kafka = process.env.KAFKA_URL && process.env.KAFKA_BROKER && (global[KAFKA] || getClient());
if (kafka) {
producer = global[KAFKA_PRODUCER] || (await getProducer());
}
})();
export default {
client: kafka,
producer: producer,
log,
getDateFormat,
sendMessage,
};

View File

@ -1,19 +1,7 @@
import { createMiddleware, unauthorized, badRequest, serverError } from 'next-basics';
import cors from 'cors'; import cors from 'cors';
import { getSession } from './session'; import { getSession } from './session';
import { getAuthToken } from './auth'; import { getAuthToken } from './auth';
import { unauthorized, badRequest, serverError } from './response';
export function createMiddleware(middleware) {
return (req, res) =>
new Promise((resolve, reject) => {
middleware(req, res, result => {
if (result instanceof Error) {
return reject(result);
}
return resolve(result);
});
});
}
export const useCors = createMiddleware(cors()); export const useCors = createMiddleware(cors());
@ -23,7 +11,9 @@ export const useSession = createMiddleware(async (req, res, next) => {
try { try {
session = await getSession(req); session = await getSession(req);
} catch (e) { } catch (e) {
// eslint-disable-next-line no-console
console.error(e); console.error(e);
return serverError(res, e.message); return serverError(res, e.message);
} }

199
lib/prisma.js Normal file
View File

@ -0,0 +1,199 @@
import { PrismaClient } from '@prisma/client';
import chalk from 'chalk';
import moment from 'moment-timezone';
import debug from 'debug';
import { PRISMA, MYSQL, POSTGRESQL, getDatabaseType } from 'lib/db';
import { FILTER_IGNORED } from 'lib/constants';
const MYSQL_DATE_FORMATS = {
minute: '%Y-%m-%d %H:%i:00',
hour: '%Y-%m-%d %H:00:00',
day: '%Y-%m-%d',
month: '%Y-%m-01',
year: '%Y-01-01',
};
const POSTGRESQL_DATE_FORMATS = {
minute: 'YYYY-MM-DD HH24:MI:00',
hour: 'YYYY-MM-DD HH24:00:00',
day: 'YYYY-MM-DD',
month: 'YYYY-MM-01',
year: 'YYYY-01-01',
};
const log = debug('umami:prisma');
const PRISMA_OPTIONS = {
log: [
{
emit: 'event',
level: 'query',
},
],
};
function logQuery(e) {
log(chalk.yellow(e.params), '->', e.query, chalk.greenBright(`${e.duration}ms`));
}
function getClient(options) {
const prisma = new PrismaClient(options);
if (process.env.LOG_QUERY) {
prisma.$on('query', logQuery);
}
if (process.env.NODE_ENV !== 'production') {
global[PRISMA] = prisma;
}
log('Prisma initialized');
return prisma;
}
function getDateQuery(field, unit, timezone) {
const db = getDatabaseType(process.env.DATABASE_URL);
if (db === POSTGRESQL) {
if (timezone) {
return `to_char(date_trunc('${unit}', ${field} at time zone '${timezone}'), '${POSTGRESQL_DATE_FORMATS[unit]}')`;
}
return `to_char(date_trunc('${unit}', ${field}), '${POSTGRESQL_DATE_FORMATS[unit]}')`;
}
if (db === MYSQL) {
if (timezone) {
const tz = moment.tz(timezone).format('Z');
return `date_format(convert_tz(${field},'+00:00','${tz}'), '${MYSQL_DATE_FORMATS[unit]}')`;
}
return `date_format(${field}, '${MYSQL_DATE_FORMATS[unit]}')`;
}
}
function getTimestampInterval(field) {
const db = getDatabaseType(process.env.DATABASE_URL);
if (db === POSTGRESQL) {
return `floor(extract(epoch from max(${field}) - min(${field})))`;
}
if (db === MYSQL) {
return `floor(unix_timestamp(max(${field})) - unix_timestamp(min(${field})))`;
}
}
function getFilterQuery(table, column, filters = {}, params = []) {
const query = Object.keys(filters).reduce((arr, key) => {
const filter = filters[key];
if (filter === undefined || filter === FILTER_IGNORED) {
return arr;
}
switch (key) {
case 'url':
if (table === 'pageview' || table === 'event') {
arr.push(`and ${table}.${key}=$${params.length + 1}`);
params.push(decodeURIComponent(filter));
}
break;
case 'os':
case 'browser':
case 'device':
case 'country':
if (table === 'session') {
arr.push(`and ${table}.${key}=$${params.length + 1}`);
params.push(decodeURIComponent(filter));
}
break;
case 'event_name':
if (table === 'event') {
arr.push(`and ${table}.${key}=$${params.length + 1}`);
params.push(decodeURIComponent(filter));
}
break;
case 'referrer':
if (table === 'pageview' || table === 'event') {
arr.push(`and ${table}.referrer like $${params.length + 1}`);
params.push(`%${decodeURIComponent(filter)}%`);
}
break;
case 'domain':
if (table === 'pageview') {
arr.push(`and ${table}.referrer not like $${params.length + 1}`);
arr.push(`and ${table}.referrer not like '/%'`);
params.push(`%://${filter}/%`);
}
break;
case 'query':
if (table === 'pageview') {
arr.push(`and ${table}.url like '%?%'`);
}
}
return arr;
}, []);
return query.join('\n');
}
function parseFilters(table, column, filters = {}, params = [], sessionKey = 'session_id') {
const { domain, url, event_url, referrer, os, browser, device, country, event_name, query } =
filters;
const pageviewFilters = { domain, url, referrer, query };
const sessionFilters = { os, browser, device, country };
const eventFilters = { url: event_url, event_name };
return {
pageviewFilters,
sessionFilters,
eventFilters,
event: { event_name },
joinSession:
os || browser || device || country
? `inner join session on ${table}.${sessionKey} = session.${sessionKey}`
: '',
pageviewQuery: getFilterQuery('pageview', column, pageviewFilters, params),
sessionQuery: getFilterQuery('session', column, sessionFilters, params),
eventQuery: getFilterQuery('event', column, eventFilters, params),
};
}
async function rawQuery(query, params = []) {
const db = getDatabaseType(process.env.DATABASE_URL);
if (db !== POSTGRESQL && db !== MYSQL) {
return Promise.reject(new Error('Unknown database.'));
}
const sql = db === MYSQL ? query.replace(/\$[0-9]+/g, '?') : query;
return prisma.$queryRawUnsafe.apply(prisma, [sql, ...params]);
}
async function transaction(queries) {
return prisma.$transaction(queries);
}
// Initialization
const prisma = global[PRISMA] || getClient(PRISMA_OPTIONS);
export default {
client: prisma,
log,
getDateQuery,
getTimestampInterval,
getFilterQuery,
parseFilters,
rawQuery,
transaction,
};

60
lib/redis.js Normal file
View File

@ -0,0 +1,60 @@
import Redis from 'ioredis';
import { startOfMonth } from 'date-fns';
import debug from 'debug';
import { getSessions, getAllWebsites } from 'queries';
import { REDIS } from 'lib/db';
const log = debug('umami:redis');
const INITIALIZED = 'redis:initialized';
export const DELETED = 'deleted';
function getClient() {
if (!process.env.REDIS_URL) {
return null;
}
const redis = new Redis(process.env.REDIS_URL);
if (process.env.NODE_ENV !== 'production') {
global[REDIS] = redis;
}
log('Redis initialized');
return redis;
}
async function stageData() {
const sessions = await getSessions([], startOfMonth(new Date()));
const websites = await getAllWebsites();
const sessionUuids = sessions.map(a => {
return { key: `session:${a.session_uuid}`, value: 1 };
});
const websiteIds = websites.map(a => {
return { key: `website:${a.website_uuid}`, value: Number(a.website_id) };
});
await addRedis(sessionUuids);
await addRedis(websiteIds);
await redis.set(INITIALIZED, 1);
}
async function addRedis(ids) {
for (let i = 0; i < ids.length; i++) {
const { key, value } = ids[i];
await redis.set(key, value);
}
}
// Initialization
const redis = process.env.REDIS_URL && (global[REDIS] || getClient());
(async () => {
if (redis && !(await redis.get(INITIALIZED))) {
await stageData();
}
})();
export default { client: redis, stageData, log };

View File

@ -1,43 +0,0 @@
export function ok(res, data = {}) {
return json(res, data);
}
export function json(res, data = {}) {
return res.status(200).json(data);
}
export function send(res, data, type = 'text/plain') {
res.setHeader('Content-Type', type);
return res.status(200).send(data);
}
export function redirect(res, url) {
res.setHeader('Location', url);
return res.status(303).end();
}
export function badRequest(res, msg = '400 Bad Request') {
return res.status(400).end(msg);
}
export function unauthorized(res, msg = '401 Unauthorized') {
return res.status(401).end(msg);
}
export function forbidden(res, msg = '403 Forbidden') {
return res.status(403).end(msg);
}
export function notFound(res, msg = '404 Not Found') {
return res.status(404).end(msg);
}
export function methodNotAllowed(res, msg = '405 Method Not Allowed') {
res.status(405).end(msg);
}
export function serverError(res, msg = '500 Internal Server Error') {
res.status(500).end(msg);
}

View File

@ -1,6 +1,9 @@
import { getWebsiteByUuid, getSessionByUuid, createSession } from 'queries'; import { parseToken } from 'next-basics';
import { getJsonBody, getClientInfo } from 'lib/request'; import { validate } from 'uuid';
import { uuid, isValidUuid, parseToken } from 'lib/crypto'; import { uuid } from 'lib/crypto';
import redis, { DELETED } from 'lib/redis';
import { getClientInfo, getJsonBody } from 'lib/request';
import { createSession, getSessionByUuid, getWebsiteByUuid } from 'queries';
export async function getSession(req) { export async function getSession(req) {
const { payload } = getJsonBody(req); const { payload } = getJsonBody(req);
@ -9,7 +12,6 @@ export async function getSession(req) {
throw new Error('Invalid request'); throw new Error('Invalid request');
} }
const { website: website_uuid, hostname, screen, language } = payload;
const cache = req.headers['x-umami-cache']; const cache = req.headers['x-umami-cache'];
if (cache) { if (cache) {
@ -20,26 +22,52 @@ export async function getSession(req) {
} }
} }
if (!isValidUuid(website_uuid)) { const { website: website_uuid, hostname, screen, language } = payload;
throw new Error(`Invalid website: ${website_uuid}`);
if (!validate(website_uuid)) {
return null;
}
let websiteId = null;
// Check if website exists
if (redis.client) {
websiteId = await redis.client.get(`website:${website_uuid}`);
}
// Check database if redis does not have
if (!websiteId) {
const website = await getWebsiteByUuid(website_uuid);
websiteId = website ? website.website_id : null;
}
if (!websiteId || websiteId === DELETED) {
throw new Error(`Website not found: ${website_uuid}`);
} }
const { userAgent, browser, os, ip, country, device } = await getClientInfo(req, payload); const { userAgent, browser, os, ip, country, device } = await getClientInfo(req, payload);
const website = await getWebsiteByUuid(website_uuid); const session_uuid = uuid(websiteId, hostname, ip, userAgent);
if (!website) { let sessionCreated = false;
throw new Error(`Website not found: ${website_uuid}`); let sessionId = null;
let session = null;
// Check if session exists
if (redis.client) {
sessionCreated = !!(await redis.client.get(`session:${session_uuid}`));
} }
const { website_id } = website; // Check database if redis does not have
const session_uuid = uuid(website_id, hostname, ip, userAgent); if (!sessionCreated) {
session = await getSessionByUuid(session_uuid);
sessionCreated = !!session;
sessionId = session ? session.session_id : null;
}
let session = await getSessionByUuid(session_uuid); if (!sessionCreated) {
if (!session) {
try { try {
session = await createSession(website_id, { session = await createSession(websiteId, {
session_uuid, session_uuid,
hostname, hostname,
browser, browser,
@ -50,21 +78,17 @@ export async function getSession(req) {
device, device,
}); });
if (!session) { sessionId = session ? session.session_id : null;
return null;
}
} catch (e) { } catch (e) {
if (!e.message.includes('Unique constraint')) { if (!e.message.toLowerCase().includes('unique constraint')) {
throw e; throw e;
} }
} }
} }
const { session_id } = session;
return { return {
website_id, website_id: websiteId,
session_id, session_id: sessionId,
session_uuid, session_uuid,
}; };
} }

View File

@ -1,35 +0,0 @@
export function removeTrailingSlash(url) {
return url && url.length > 1 && url.endsWith('/') ? url.slice(0, -1) : url;
}
export function removeWWW(url) {
return url && url.length > 1 && url.startsWith('www.') ? url.slice(4) : url;
}
export function getQueryString(params = {}) {
const map = Object.keys(params).reduce((arr, key) => {
if (params[key] !== undefined) {
return arr.concat(`${key}=${encodeURIComponent(params[key])}`);
}
return arr;
}, []);
if (map.length) {
return `?${map.join('&')}`;
}
return '';
}
export function makeUrl(url, params) {
return `${url}${getQueryString(params)}`;
}
export function safeDecodeURI(s) {
try {
return decodeURI(s);
} catch (e) {
console.error(e);
}
return s;
}

View File

@ -1,78 +0,0 @@
import { makeUrl } from './url';
export const apiRequest = (method, url, body, headers) => {
return fetch(url, {
method,
cache: 'no-cache',
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...headers,
},
body,
}).then(res => {
if (res.ok) {
return res.json().then(data => ({ ok: res.ok, status: res.status, data }));
}
return res.text().then(data => ({ ok: res.ok, status: res.status, res: res, data }));
});
};
export const get = (url, params, headers) =>
apiRequest('get', makeUrl(url, params), undefined, headers);
export const del = (url, params, headers) =>
apiRequest('delete', makeUrl(url, params), undefined, headers);
export const post = (url, params, headers) =>
apiRequest('post', url, JSON.stringify(params), headers);
export const put = (url, params, headers) =>
apiRequest('put', url, JSON.stringify(params), headers);
export const hook = (_this, method, callback) => {
const orig = _this[method];
return (...args) => {
callback.apply(null, args);
return orig.apply(_this, args);
};
};
export const doNotTrack = () => {
const { doNotTrack, navigator, external } = window;
const msTrackProtection = 'msTrackingProtectionEnabled';
const msTracking = () => {
return external && msTrackProtection in external && external[msTrackProtection]();
};
const dnt = doNotTrack || navigator.doNotTrack || navigator.msDoNotTrack || msTracking();
return dnt == '1' || dnt === 'yes';
};
export const setItem = (key, data, session) => {
if (typeof window !== 'undefined' && data) {
(session ? sessionStorage : localStorage).setItem(key, JSON.stringify(data));
}
};
export const getItem = (key, session) => {
if (typeof window !== 'undefined') {
const value = (session ? sessionStorage : localStorage).getItem(key);
if (value !== 'undefined') {
return JSON.parse(value);
}
}
};
export const removeItem = (key, session) => {
if (typeof window !== 'undefined') {
(session ? sessionStorage : localStorage).removeItem(key);
}
};

View File

@ -1,2 +1,2 @@
[functions] [functions]
included_files = ["public/geo/*.mmdb"] included_files = ["node_modules/.geo/**"]

View File

@ -1,6 +1,6 @@
{ {
"name": "umami", "name": "umami",
"version": "1.37.0", "version": "1.38.0",
"description": "A simple, fast, privacy-focused alternative to Google Analytics.", "description": "A simple, fast, privacy-focused alternative to Google Analytics.",
"author": "Mike Cao <mike@mikecao.com>", "author": "Mike Cao <mike@mikecao.com>",
"license": "MIT", "license": "MIT",
@ -11,8 +11,9 @@
}, },
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "npm-run-all build-tracker build-geo build-db build-app", "build": "npm-run-all build-db check-db build-tracker build-geo build-app",
"start": "npm-run-all check-db start-next", "start": "start-next",
"build-docker": "npm-run-all build-db build-tracker build-geo build-app",
"start-docker": "npm-run-all check-db update-tracker start-server", "start-docker": "npm-run-all check-db update-tracker start-server",
"start-env": "node scripts/start-env.js", "start-env": "node scripts/start-env.js",
"start-server": "node server.js", "start-server": "node server.js",
@ -56,8 +57,7 @@
}, },
"dependencies": { "dependencies": {
"@fontsource/inter": "4.5.7", "@fontsource/inter": "4.5.7",
"@prisma/client": "4.1.1", "@prisma/client": "4.3.1",
"bcryptjs": "^2.4.3",
"chalk": "^4.1.1", "chalk": "^4.1.1",
"chart.js": "^2.9.4", "chart.js": "^2.9.4",
"classnames": "^2.3.1", "classnames": "^2.3.1",
@ -67,21 +67,25 @@
"cross-spawn": "^7.0.3", "cross-spawn": "^7.0.3",
"date-fns": "^2.23.0", "date-fns": "^2.23.0",
"date-fns-tz": "^1.1.4", "date-fns-tz": "^1.1.4",
"dateformat": "^5.0.3",
"debug": "^4.3.4",
"del": "^6.0.0", "del": "^6.0.0",
"detect-browser": "^5.2.0", "detect-browser": "^5.2.0",
"dotenv": "^10.0.0", "dotenv": "^10.0.0",
"formik": "^2.2.9", "formik": "^2.2.9",
"fs-extra": "^10.0.1", "fs-extra": "^10.0.1",
"immer": "^9.0.12", "immer": "^9.0.12",
"ioredis": "^5.2.3",
"ipaddr.js": "^2.0.1", "ipaddr.js": "^2.0.1",
"is-ci": "^3.0.1", "is-ci": "^3.0.1",
"is-docker": "^3.0.0", "is-docker": "^3.0.0",
"is-localhost-ip": "^1.4.0", "is-localhost-ip": "^1.4.0",
"isbot": "^3.4.5", "isbot": "^3.4.5",
"jose": "2.0.5", "kafkajs": "^2.1.0",
"maxmind": "^4.3.6", "maxmind": "^4.3.6",
"moment-timezone": "^0.5.35", "moment-timezone": "^0.5.35",
"next": "^12.2.4", "next": "^12.2.5",
"next-basics": "^0.7.0",
"node-fetch": "^3.2.8", "node-fetch": "^3.2.8",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"prop-types": "^15.7.2", "prop-types": "^15.7.2",
@ -94,7 +98,7 @@
"react-tooltip": "^4.2.21", "react-tooltip": "^4.2.21",
"react-use-measure": "^2.0.4", "react-use-measure": "^2.0.4",
"react-window": "^1.8.6", "react-window": "^1.8.6",
"request-ip": "^2.1.3", "request-ip": "^3.3.0",
"semver": "^7.3.6", "semver": "^7.3.6",
"thenby": "^1.3.4", "thenby": "^1.3.4",
"timezone-support": "^2.0.2", "timezone-support": "^2.0.2",
@ -110,6 +114,8 @@
"eslint": "^7.32.0", "eslint": "^7.32.0",
"eslint-config-next": "^12.2.4", "eslint-config-next": "^12.2.4",
"eslint-config-prettier": "^8.5.0", "eslint-config-prettier": "^8.5.0",
"eslint-import-resolver-alias": "^1.1.2",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-prettier": "^4.0.0", "eslint-plugin-prettier": "^4.0.0",
"extract-react-intl-messages": "^4.1.1", "extract-react-intl-messages": "^4.1.1",
"husky": "^7.0.0", "husky": "^7.0.0",
@ -120,7 +126,7 @@
"postcss-preset-env": "7.4.3", "postcss-preset-env": "7.4.3",
"postcss-rtlcss": "^3.6.1", "postcss-rtlcss": "^3.6.1",
"prettier": "^2.6.2", "prettier": "^2.6.2",
"prisma": "4.1.1", "prisma": "4.3.1",
"prompts": "2.4.2", "prompts": "2.4.2",
"rollup": "^2.70.1", "rollup": "^2.70.1",
"rollup-plugin-terser": "^7.0.2", "rollup-plugin-terser": "^7.0.2",

View File

@ -1,6 +1,6 @@
import { getAccountById, deleteAccount } from 'queries'; import { getAccountById, deleteAccount } from 'queries';
import { useAuth } from 'lib/middleware'; import { useAuth } from 'lib/middleware';
import { methodNotAllowed, ok, unauthorized } from 'lib/response'; import { methodNotAllowed, ok, unauthorized } from 'next-basics';
export default async (req, res) => { export default async (req, res) => {
await useAuth(req, res); await useAuth(req, res);

View File

@ -1,7 +1,6 @@
import { ok, unauthorized, methodNotAllowed, badRequest, hashPassword } from 'next-basics';
import { getAccountById, getAccountByUsername, updateAccount, createAccount } from 'queries'; import { getAccountById, getAccountByUsername, updateAccount, createAccount } from 'queries';
import { useAuth } from 'lib/middleware'; import { useAuth } from 'lib/middleware';
import { hashPassword } from 'lib/crypto';
import { ok, unauthorized, methodNotAllowed, badRequest } from 'lib/response';
export default async (req, res) => { export default async (req, res) => {
await useAuth(req, res); await useAuth(req, res);

View File

@ -1,7 +1,13 @@
import { getAccountById, updateAccount } from 'queries'; import { getAccountById, updateAccount } from 'queries';
import { useAuth } from 'lib/middleware'; import { useAuth } from 'lib/middleware';
import { badRequest, methodNotAllowed, ok, unauthorized } from 'lib/response'; import {
import { checkPassword, hashPassword } from 'lib/crypto'; badRequest,
methodNotAllowed,
ok,
unauthorized,
checkPassword,
hashPassword,
} from 'next-basics';
export default async (req, res) => { export default async (req, res) => {
await useAuth(req, res); await useAuth(req, res);

View File

@ -1,6 +1,6 @@
import { getAccounts } from 'queries'; import { getAccounts } from 'queries';
import { useAuth } from 'lib/middleware'; import { useAuth } from 'lib/middleware';
import { ok, unauthorized, methodNotAllowed } from 'lib/response'; import { ok, unauthorized, methodNotAllowed } from 'next-basics';
export default async (req, res) => { export default async (req, res) => {
await useAuth(req, res); await useAuth(req, res);

View File

@ -1,6 +1,6 @@
import { checkPassword, createSecureToken } from 'lib/crypto'; import { ok, unauthorized, badRequest, checkPassword, createSecureToken } from 'next-basics';
import { getAccountByUsername } from 'queries/admin/account/getAccountByUsername'; import { getAccountByUsername } from 'queries/admin/account/getAccountByUsername';
import { ok, unauthorized, badRequest } from 'lib/response'; import { secret } from 'lib/crypto';
export default async (req, res) => { export default async (req, res) => {
const { username, password } = req.body; const { username, password } = req.body;
@ -11,10 +11,10 @@ export default async (req, res) => {
const account = await getAccountByUsername(username); const account = await getAccountByUsername(username);
if (account && (await checkPassword(password, account.password))) { if (account && checkPassword(password, account.password)) {
const { user_id, username, is_admin } = account; const { user_id, username, is_admin } = account;
const user = { user_id, username, is_admin }; const user = { user_id, username, is_admin };
const token = await createSecureToken(user); const token = createSecureToken(user, secret());
return ok(res, { token, user }); return ok(res, { token, user });
} }

View File

@ -1,5 +1,5 @@
import { useAuth } from 'lib/middleware'; import { useAuth } from 'lib/middleware';
import { ok, unauthorized } from 'lib/response'; import { ok, unauthorized } from 'next-basics';
export default async (req, res) => { export default async (req, res) => {
await useAuth(req, res); await useAuth(req, res);

View File

@ -1,18 +1,17 @@
const { Resolver } = require('dns').promises; const { Resolver } = require('dns').promises;
import isbot from 'isbot'; import isbot from 'isbot';
import ipaddr from 'ipaddr.js'; import ipaddr from 'ipaddr.js';
import { createToken, unauthorized, send, badRequest, forbidden } from 'next-basics';
import { savePageView, saveEvent } from 'queries'; import { savePageView, saveEvent } from 'queries';
import { useCors, useSession } from 'lib/middleware'; import { useCors, useSession } from 'lib/middleware';
import { getJsonBody, getIpAddress } from 'lib/request'; import { getJsonBody, getIpAddress } from 'lib/request';
import { ok, send, badRequest, forbidden } from 'lib/response'; import { secret, uuid } from 'lib/crypto';
import { createToken } from 'lib/crypto';
import { removeTrailingSlash } from 'lib/url';
export default async (req, res) => { export default async (req, res) => {
await useCors(req, res); await useCors(req, res);
if (isbot(req.headers['user-agent'])) { if (isbot(req.headers['user-agent']) && !process.env.DISABLE_BOT_CHECK) {
return ok(res); return unauthorized(res);
} }
const ignoreIps = process.env.IGNORE_IP; const ignoreIps = process.env.IGNORE_IP;
@ -68,18 +67,27 @@ export default async (req, res) => {
let { url, referrer, event_name, event_data } = payload; let { url, referrer, event_name, event_data } = payload;
if (process.env.REMOVE_TRAILING_SLASH) { if (process.env.REMOVE_TRAILING_SLASH) {
url = removeTrailingSlash(url); url = url.replace(/\/$/, '');
} }
const event_uuid = uuid();
if (type === 'pageview') { if (type === 'pageview') {
await savePageView(website_id, { session_id, session_uuid, url, referrer }); await savePageView(website_id, { session_id, session_uuid, url, referrer });
} else if (type === 'event') { } else if (type === 'event') {
await saveEvent(website_id, { session_id, session_uuid, url, event_name, event_data }); await saveEvent(website_id, {
event_uuid,
session_id,
session_uuid,
url,
event_name,
event_data,
});
} else { } else {
return badRequest(res); return badRequest(res);
} }
const token = await createToken({ website_id, session_id }); const token = createToken({ website_id, session_id, session_uuid }, secret());
return send(res, token); return send(res, token);
}; };

View File

@ -1,4 +1,4 @@
import { ok, methodNotAllowed } from 'lib/response'; import { ok, methodNotAllowed } from 'next-basics';
export default async (req, res) => { export default async (req, res) => {
if (req.method === 'GET') { if (req.method === 'GET') {

5
pages/api/heartbeat.js Normal file
View File

@ -0,0 +1,5 @@
import { ok } from 'next-basics';
export default async (req, res) => {
return ok(res);
};

View File

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

View File

@ -1,8 +1,8 @@
import { ok, methodNotAllowed, badRequest, parseToken } from 'next-basics';
import { useAuth } from 'lib/middleware'; import { useAuth } from 'lib/middleware';
import { ok, methodNotAllowed, badRequest } from 'lib/response';
import { getRealtimeData } from 'queries'; import { getRealtimeData } from 'queries';
import { parseToken } from 'lib/crypto';
import { SHARE_TOKEN_HEADER } from 'lib/constants'; import { SHARE_TOKEN_HEADER } from 'lib/constants';
import { secret } from 'lib/crypto';
export default async (req, res) => { export default async (req, res) => {
await useAuth(req, res); await useAuth(req, res);
@ -16,7 +16,7 @@ export default async (req, res) => {
return badRequest(res); return badRequest(res);
} }
const { websites } = await parseToken(token); const { websites } = parseToken(token, secret());
const data = await getRealtimeData(websites, new Date(+start_at)); const data = await getRealtimeData(websites, new Date(+start_at));

View File

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

View File

@ -1,4 +1,4 @@
import { methodNotAllowed, ok, unauthorized } from 'lib/response'; import { methodNotAllowed, ok, unauthorized } from 'next-basics';
import { allowQuery } from 'lib/auth'; import { allowQuery } from 'lib/auth';
import { useCors } from 'lib/middleware'; import { useCors } from 'lib/middleware';
import { getActiveVisitors } from 'queries'; import { getActiveVisitors } from 'queries';

View File

@ -1,6 +1,6 @@
import moment from 'moment-timezone'; import moment from 'moment-timezone';
import { getEventMetrics } from 'queries'; import { getEventMetrics } from 'queries';
import { ok, badRequest, methodNotAllowed, unauthorized } from 'lib/response'; import { ok, badRequest, methodNotAllowed, unauthorized } from 'next-basics';
import { allowQuery } from 'lib/auth'; import { allowQuery } from 'lib/auth';
import { useCors } from 'lib/middleware'; import { useCors } from 'lib/middleware';

View File

@ -1,5 +1,5 @@
import { methodNotAllowed, ok, unauthorized } from 'next-basics';
import { deleteWebsite, getWebsiteById } from 'queries'; import { deleteWebsite, getWebsiteById } from 'queries';
import { methodNotAllowed, ok, unauthorized } from 'lib/response';
import { allowQuery } from 'lib/auth'; import { allowQuery } from 'lib/auth';
import { useCors } from 'lib/middleware'; import { useCors } from 'lib/middleware';

View File

@ -1,5 +1,5 @@
import { getPageviewMetrics, getSessionMetrics, getWebsiteById } from 'queries'; import { getPageviewMetrics, getSessionMetrics, getWebsiteById } from 'queries';
import { ok, methodNotAllowed, unauthorized, badRequest } from 'lib/response'; import { ok, methodNotAllowed, unauthorized, badRequest } from 'next-basics';
import { allowQuery } from 'lib/auth'; import { allowQuery } from 'lib/auth';
import { useCors } from 'lib/middleware'; import { useCors } from 'lib/middleware';
import { FILTER_IGNORED } from 'lib/constants'; import { FILTER_IGNORED } from 'lib/constants';

View File

@ -1,6 +1,6 @@
import moment from 'moment-timezone'; import moment from 'moment-timezone';
import { getPageviewStats } from 'queries'; import { getPageviewStats } from 'queries';
import { ok, badRequest, methodNotAllowed, unauthorized } from 'lib/response'; import { ok, badRequest, methodNotAllowed, unauthorized } from 'next-basics';
import { allowQuery } from 'lib/auth'; import { allowQuery } from 'lib/auth';
import { useCors } from 'lib/middleware'; import { useCors } from 'lib/middleware';

View File

@ -1,5 +1,5 @@
import { resetWebsite } from 'queries'; import { resetWebsite } from 'queries';
import { methodNotAllowed, ok, unauthorized } from 'lib/response'; import { methodNotAllowed, ok, unauthorized } from 'next-basics';
import { allowQuery } from 'lib/auth'; import { allowQuery } from 'lib/auth';
export default async (req, res) => { export default async (req, res) => {

View File

@ -1,5 +1,5 @@
import { getWebsiteStats } from 'queries'; import { getWebsiteStats } from 'queries';
import { methodNotAllowed, ok, unauthorized } from 'lib/response'; import { methodNotAllowed, ok, unauthorized } from 'next-basics';
import { allowQuery } from 'lib/auth'; import { allowQuery } from 'lib/auth';
import { useCors } from 'lib/middleware'; import { useCors } from 'lib/middleware';

View File

@ -1,7 +1,7 @@
import { ok, unauthorized, methodNotAllowed, getRandomChars } from 'next-basics';
import { updateWebsite, createWebsite, getWebsiteById } from 'queries'; import { updateWebsite, createWebsite, getWebsiteById } from 'queries';
import { useAuth } from 'lib/middleware'; import { useAuth } from 'lib/middleware';
import { uuid, getRandomChars } from 'lib/crypto'; import { uuid } from 'lib/crypto';
import { ok, unauthorized, methodNotAllowed } from 'lib/response';
export default async (req, res) => { export default async (req, res) => {
await useAuth(req, res); await useAuth(req, res);
@ -10,7 +10,8 @@ export default async (req, res) => {
const { website_id, enable_share_url } = req.body; const { website_id, enable_share_url } = req.body;
if (req.method === 'POST') { if (req.method === 'POST') {
const { name, domain } = req.body; const { name, domain, owner } = req.body;
const website_owner = parseInt(owner);
if (website_id) { if (website_id) {
const website = await getWebsiteById(website_id); const website = await getWebsiteById(website_id);
@ -27,13 +28,13 @@ export default async (req, res) => {
share_id = null; share_id = null;
} }
await updateWebsite(website_id, { name, domain, share_id }); await updateWebsite(website_id, { name, domain, share_id, user_id: website_owner });
return ok(res); return ok(res);
} else { } else {
const website_uuid = uuid(); const website_uuid = uuid();
const share_id = enable_share_url ? getRandomChars(8) : null; const share_id = enable_share_url ? getRandomChars(8) : null;
const website = await createWebsite(user_id, { website_uuid, name, domain, share_id }); const website = await createWebsite(website_owner, { website_uuid, name, domain, share_id });
return ok(res, website); return ok(res, website);
} }

View File

@ -1,6 +1,6 @@
import { getAllWebsites, getUserWebsites } from 'queries'; import { getAllWebsites, getUserWebsites } from 'queries';
import { useAuth } from 'lib/middleware'; import { useAuth } from 'lib/middleware';
import { ok, methodNotAllowed, unauthorized } from 'lib/response'; import { ok, methodNotAllowed, unauthorized } from 'next-basics';
export default async (req, res) => { export default async (req, res) => {
await useAuth(req, res); await useAuth(req, res);

View File

@ -1,6 +1,6 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { removeItem } from 'lib/web'; import { removeItem } from 'next-basics';
import { AUTH_TOKEN } from 'lib/constants'; import { AUTH_TOKEN } from 'lib/constants';
import { setUser } from 'store/app'; import { setUser } from 'store/app';

View File

@ -397,6 +397,12 @@
"value": "Llocs web" "value": "Llocs web"
} }
], ],
"label.yesterday": [
{
"type": 0,
"value": "Ahir"
}
],
"message.active-users": [ "message.active-users": [
{ {
"type": 1, "type": 1,

View File

@ -397,6 +397,12 @@
"value": "Webseiten" "value": "Webseiten"
} }
], ],
"label.yesterday": [
{
"type": 0,
"value": "Gestern"
}
],
"message.active-users": [ "message.active-users": [
{ {
"type": 1, "type": 1,
@ -474,7 +480,7 @@
"message.edit-dashboard": [ "message.edit-dashboard": [
{ {
"type": 0, "type": 0,
"value": "Edit dashboard" "value": "Dashboard bearbeiten"
} }
], ],
"message.failure": [ "message.failure": [
@ -770,7 +776,7 @@
"metrics.query-parameters": [ "metrics.query-parameters": [
{ {
"type": 0, "type": 0,
"value": "Query parameters" "value": "Abfrageparameter"
} }
], ],
"metrics.referrers": [ "metrics.referrers": [

View File

@ -397,6 +397,12 @@
"value": "Websites" "value": "Websites"
} }
], ],
"label.yesterday": [
{
"type": 0,
"value": "Yesterday"
}
],
"message.active-users": [ "message.active-users": [
{ {
"type": 1, "type": 1,

View File

@ -397,6 +397,12 @@
"value": "Websites" "value": "Websites"
} }
], ],
"label.yesterday": [
{
"type": 0,
"value": "Yesterday"
}
],
"message.active-users": [ "message.active-users": [
{ {
"type": 1, "type": 1,

View File

@ -397,6 +397,12 @@
"value": "Sitios" "value": "Sitios"
} }
], ],
"label.yesterday": [
{
"type": 0,
"value": "Ayer"
}
],
"message.active-users": [ "message.active-users": [
{ {
"type": 1, "type": 1,

View File

@ -38,7 +38,7 @@
"label.all-websites": [ "label.all-websites": [
{ {
"type": 0, "type": 0,
"value": "Tous les sites web" "value": "Tous les sites"
} }
], ],
"label.back": [ "label.back": [
@ -170,7 +170,7 @@
"label.language": [ "label.language": [
{ {
"type": 0, "type": 0,
"value": "Langage" "value": "Langue"
} }
], ],
"label.last-days": [ "label.last-days": [
@ -332,7 +332,7 @@
"label.this-month": [ "label.this-month": [
{ {
"type": 0, "type": 0,
"value": "Ce mois ci" "value": "Ce mois"
} }
], ],
"label.this-week": [ "label.this-week": [
@ -389,6 +389,12 @@
"value": "Sites" "value": "Sites"
} }
], ],
"label.yesterday": [
{
"type": 0,
"value": "Hier"
}
],
"message.active-users": [ "message.active-users": [
{ {
"type": 1, "type": 1,
@ -444,7 +450,7 @@
"message.confirm-reset": [ "message.confirm-reset": [
{ {
"type": 0, "type": 0,
"value": "Êtes-vous sûr de vouloir réinistialiser les statistiques de " "value": "Êtes-vous sûr de vouloir réinitialiser les statistiques de "
}, },
{ {
"type": 1, "type": 1,
@ -470,7 +476,7 @@
"message.edit-dashboard": [ "message.edit-dashboard": [
{ {
"type": 0, "type": 0,
"value": "Edit dashboard" "value": "Modifier le tableau de bord"
} }
], ],
"message.failure": [ "message.failure": [
@ -560,7 +566,7 @@
"message.no-websites-configured": [ "message.no-websites-configured": [
{ {
"type": 0, "type": 0,
"value": "Vous n'avez configuré aucun site Web." "value": "Vous n'avez configuré aucun site."
} }
], ],
"message.page-not-found": [ "message.page-not-found": [
@ -582,7 +588,7 @@
"message.reset-warning": [ "message.reset-warning": [
{ {
"type": 0, "type": 0,
"value": "Toutes les statistiques pour ce site seront supprimés, mais votre code de suivi restera intact." "value": "Toutes les statistiques pour ce site seront supprimées, mais votre code de suivi restera intact."
} }
], ],
"message.save-success": [ "message.save-success": [
@ -630,7 +636,7 @@
}, },
{ {
"type": 0, "type": 0,
"value": " de votre site Web." "value": " de votre site."
} }
], ],
"message.type-delete": [ "message.type-delete": [
@ -742,7 +748,7 @@
"metrics.languages": [ "metrics.languages": [
{ {
"type": 0, "type": 0,
"value": "Langages" "value": "Langues"
} }
], ],
"metrics.operating-systems": [ "metrics.operating-systems": [
@ -766,7 +772,7 @@
"metrics.query-parameters": [ "metrics.query-parameters": [
{ {
"type": 0, "type": 0,
"value": "Query parameters" "value": "Paramètres d'URL"
} }
], ],
"metrics.referrers": [ "metrics.referrers": [
@ -778,7 +784,7 @@
"metrics.screens": [ "metrics.screens": [
{ {
"type": 0, "type": 0,
"value": "Tailles d'écran" "value": "Résolutions d'écran"
} }
], ],
"metrics.unique-visitors": [ "metrics.unique-visitors": [

View File

@ -397,6 +397,12 @@
"value": "Siti web" "value": "Siti web"
} }
], ],
"label.yesterday": [
{
"type": 0,
"value": "Ieri"
}
],
"message.active-users": [ "message.active-users": [
{ {
"type": 1, "type": 1,

View File

@ -454,7 +454,7 @@
"message.edit-dashboard": [ "message.edit-dashboard": [
{ {
"type": 0, "type": 0,
"value": "Edit dashboard" "value": "编辑仪表板"
} }
], ],
"message.failure": [ "message.failure": [
@ -758,7 +758,7 @@
"metrics.query-parameters": [ "metrics.query-parameters": [
{ {
"type": 0, "type": 0,
"value": "Query parameters" "value": "查询参数"
} }
], ],
"metrics.referrers": [ "metrics.referrers": [

View File

@ -170,7 +170,7 @@
"label.language": [ "label.language": [
{ {
"type": 0, "type": 0,
"value": "Language" "value": "語言"
} }
], ],
"label.last-days": [ "label.last-days": [
@ -454,7 +454,7 @@
"message.edit-dashboard": [ "message.edit-dashboard": [
{ {
"type": 0, "type": 0,
"value": "Edit dashboard" "value": "編輯管理面板"
} }
], ],
"message.failure": [ "message.failure": [
@ -754,7 +754,7 @@
"metrics.query-parameters": [ "metrics.query-parameters": [
{ {
"type": 0, "type": 0,
"value": "Query parameters" "value": "查詢參數"
} }
], ],
"metrics.referrers": [ "metrics.referrers": [

View File

@ -1,9 +1,7 @@
import { prisma, runQuery } from 'lib/db'; import prisma from 'lib/prisma';
export async function createAccount(data) { export async function createAccount(data) {
return runQuery( return prisma.client.account.create({
prisma.account.create({ data,
data, });
}),
);
} }

View File

@ -1,11 +1,50 @@
import { prisma, runQuery } from 'lib/db'; import prisma from 'lib/prisma';
import redis, { DELETED } from 'lib/redis';
export async function deleteAccount(user_id) { export async function deleteAccount(user_id) {
return runQuery( const { client } = prisma;
prisma.account.delete({
where: { const websites = await client.website.findMany({
user_id, where: { user_id },
}, select: { website_uuid: true },
}), });
);
let websiteUuids = [];
if (websites.length > 0) {
websiteUuids = websites.map(a => a.website_uuid);
}
return client
.$transaction([
client.pageview.deleteMany({
where: { session: { website: { user_id } } },
}),
client.event_data.deleteMany({
where: { event: { session: { website: { user_id } } } },
}),
client.event.deleteMany({
where: { session: { website: { user_id } } },
}),
client.session.deleteMany({
where: { website: { user_id } },
}),
client.website.deleteMany({
where: { user_id },
}),
client.account.delete({
where: {
user_id,
},
}),
])
.then(async res => {
if (redis.client) {
for (let i = 0; i < websiteUuids.length; i++) {
await redis.client.set(`website:${websiteUuids[i]}`, DELETED);
}
}
return res;
});
} }

Some files were not shown because too many files have changed in this diff Show More